4.9 — Background execution

Opening scenario

User taps your podcast app’s “download episode” button, then locks the phone and shoves it in a pocket. Twenty minutes later, on the bus, they pull out the phone, open the app — and the episode is downloaded, the next-up queue refreshed, listening history synced.

iOS is aggressive about suspending apps. The OS prefers your app uses zero CPU, zero radio, zero battery when not foregrounded. Background execution is a system of explicit, narrow permissions: each one says “you may do this specific thing for this much time.”

NeedAPI
Finish a task user just kicked off (~30s)UIApplication.beginBackgroundTask
Periodic refresh (“update content overnight”)BGAppRefreshTask
Heavy work on power + Wi-Fi (“re-index database”)BGProcessingTask
Download / upload that survives app suspensionBackground URLSession
Audio playing while screen offAudio background mode
Location updates in backgroundLocation background mode + entitlement
Server-pushed updatesSilent push notifications

Concept → Why → How → Code

App lifecycle recap

In iOS, your process states (from UIScene.activationState / app delegate):

  1. Not running — never launched, or terminated by user/OS
  2. Inactive — foreground but not receiving events (e.g., user pulled down Control Center)
  3. Active — foreground and receiving events
  4. Background — runs briefly after going to background; will be suspended
  5. Suspended — frozen in memory; OS may kill it any time

Background tasks let you do work in state 4 before becoming suspended, or get launched into state 4 for periodic work.

beginBackgroundTask — finish what you started

When the user backgrounds the app mid-operation (uploading a comment, saving a draft), you get ~30 seconds to finish:

let taskID = UIApplication.shared.beginBackgroundTask(withName: "SubmitComment") {
    // Expiration handler — called when time runs out
    cleanUp()
    UIApplication.shared.endBackgroundTask(taskID)
}

Task {
    defer { UIApplication.shared.endBackgroundTask(taskID) }
    do {
        try await api.send(commentRequest)
    } catch {
        log(error)
    }
}

Rules:

  • Always call endBackgroundTask — even on error, even in the expiration handler. Failing to do so eventually crashes the app for hogging background time.
  • Pair with beginBackgroundTask 1:1. You can have multiple concurrent task IDs.
  • Don’t expect more than ~30 seconds. Earlier iOS versions gave 3 minutes; modern iOS is stingier.

Use case: user submits a form, hits home before the request finishes. Without beginBackgroundTask, the app suspends instantly and the request fails.

BGTaskScheduler — periodic background work

For “every now and then, refresh content” or “occasionally re-process data,” use BackgroundTasks framework (iOS 13+).

Register identifiers in Info.plist:

<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
    <string>com.myapp.refresh</string>
    <string>com.myapp.cleanup</string>
</array>

Register handlers at launch (must happen before application(_:didFinishLaunchingWithOptions:) returns):

import BackgroundTasks

BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.myapp.refresh", using: nil) { task in
    handleRefresh(task: task as! BGAppRefreshTask)
}

BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.myapp.cleanup", using: nil) { task in
    handleCleanup(task: task as! BGProcessingTask)
}

Schedule work when the app goes to background:

func scheduleAppRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.myapp.refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60)  // earliest 1 hour from now
    do {
        try BGTaskScheduler.shared.submit(request)
    } catch {
        print("Could not schedule app refresh: \(error)")
    }
}

iOS decides when (based on usage patterns, power, network). You get called when it picks your moment.

Handler — finish quickly (~30s for refresh, longer for processing) and must call setTaskCompleted:

func handleRefresh(task: BGAppRefreshTask) {
    scheduleAppRefresh()   // chain the next one

    task.expirationHandler = {
        // OS reclaiming time; cancel ongoing work
        currentTask?.cancel()
    }

    currentTask = Task {
        do {
            try await refreshContent()
            task.setTaskCompleted(success: true)
        } catch {
            task.setTaskCompleted(success: false)
        }
    }
}

BGProcessingTask is for heavier work that needs power and/or network — re-indexing local DB, downloading large updates. You can require requiresNetworkConnectivity = true and requiresExternalPower = true so the OS only triggers when plugged in on Wi-Fi.

Test the handler manually — iOS won’t run it on demand for you. From LLDB while paused:

e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.myapp.refresh"]

(This is a private API — debug only.)

Background URLSession

Covered in 4.8 but recapped: URLSessionConfiguration.background(withIdentifier:) keeps downloads/uploads running after suspension. OS launches your app in background on completion via application(_:handleEventsForBackgroundURLSession:completionHandler:). Implement the delegate to save the completionHandler, finalize, then invoke it so iOS knows you’re done.

Silent push notifications

For server-pushed background refresh (e.g., new message arrived, update local cache before user opens app):

  1. Enable “Remote Notifications” capability + “Background Modes → Remote notifications”
  2. Server sends APNs payload with "content-available": 1:
{
    "aps": { "content-available": 1 },
    "messageId": "abc123"
}
  1. iOS wakes your app and calls:
func application(_ app: UIApplication,
                 didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
    do {
        try await syncNewMessages(triggeredBy: userInfo)
        return .newData
    } catch {
        return .failed
    }
}

Return one of .newData / .noData / .failed so iOS calibrates how often to wake you.

Caveats:

  • iOS may throttle silent pushes (rate-limit, defer). Not guaranteed delivery for waking up the app.
  • User can disable “Background App Refresh” in Settings — your silent pushes won’t wake the app.
  • Don’t use silent push for time-critical actions; use a regular alerting push.

Audio in background

For podcast / music apps, enable Audio background mode:

<key>UIBackgroundModes</key>
<array>
    <string>audio</string>
</array>

Configure the audio session for playback:

import AVFoundation

try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [])
try AVAudioSession.sharedInstance().setActive(true)

Now AVPlayer.play() continues after lock screen. Combine with MPNowPlayingInfoCenter to show track info on lock screen and Control Center:

import MediaPlayer

MPNowPlayingInfoCenter.default().nowPlayingInfo = [
    MPMediaItemPropertyTitle: episode.title,
    MPMediaItemPropertyArtist: episode.showName,
    MPMediaItemPropertyPlaybackDuration: episode.duration,
    MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime().seconds
]

let cmd = MPRemoteCommandCenter.shared()
cmd.playCommand.addTarget { _ in player.play(); return .success }
cmd.pauseCommand.addTarget { _ in player.pause(); return .success }

Location in background

Two modes:

  • Significant location changes (battery-friendly, ~500m precision) — works fully suspended
  • Standard continuous updates (precise but battery-hungry) — requires Always authorization + entitlement
let manager = CLLocationManager()
manager.delegate = self
manager.requestAlwaysAuthorization()
manager.allowsBackgroundLocationUpdates = true
manager.pausesLocationUpdatesAutomatically = true
manager.startMonitoringSignificantLocationChanges()

For Uber-style ride-tracking, enable showsBackgroundLocationIndicator = true (blue bar on top while tracking — required for Always-authorized continuous updates).

Combining modes

Many apps combine: audio + remote notifications + background fetch. Each requires its own UIBackgroundModes entry and matching capabilities. Apple reviews these; lying about why you need them is a fast App Store rejection.

Energy & responsibility

Background execution costs battery. iOS measures this and exposes it to the user (Settings → Battery → app usage). Apps with bad reputation get throttled — silent pushes ignored, BGAppRefresh rarely scheduled.

Best practices:

  • Do the minimum work each background invocation
  • Coalesce: if you need to sync 5 things, do them in one background pass, not five
  • Respect Low Power Mode (ProcessInfo.processInfo.isLowPowerModeEnabled is true) — skip non-essential refreshes
  • Use Wi-Fi when available (config.allowsCellularAccess = false on opportunistic syncs)

Debugging

  • Console.app on macOS with the device connected shows OSLog messages and system “ran your background task” entries
  • Xcode → Debug → Simulate Background Fetch triggers the legacy fetch API (deprecated, but somewhat useful)
  • Xcode → Debug → Simulate Push Notification with a JSON file triggers silent pushes in the simulator
  • Settings → Developer → Background Task on iOS device gives you manual triggers
  • OSLog with subsystem and category — filter system logs to your app’s background activity

In the wild

  • Spotify uses background audio + background URL sessions for downloaded podcasts/songs.
  • Strava uses background location (significant changes + standard with paused updates between activities).
  • Pocket prefetches articles using BGAppRefreshTask overnight on Wi-Fi.
  • Slack uses silent pushes to pre-fetch new messages; your app shows them instantly on next open.
  • Apple Photos does heavy ML re-indexing (faces, objects) via BGProcessingTask with requiresExternalPower = true — runs at night while charging.

Common misconceptions

  1. beginBackgroundTask gives me unlimited time.” No — ~30 seconds max. Expiration handler fires; you must clean up.
  2. BGAppRefreshTask runs on a schedule I set.” You request an earliest time. iOS decides actual scheduling based on usage patterns, power, network. Could be 1 hour from now, could be 12 hours.
  3. “Silent push is reliable for delivery.” It’s best-effort. APNs may coalesce, defer, or drop them — especially if your app’s battery reputation is bad.
  4. “I can use background modes to run arbitrary code.” No — each background mode unlocks one specific capability. Apple reviews and rejects misuse.
  5. “Background fetch is the same as BGAppRefresh.” UIApplication.backgroundFetch is deprecated. Use BGTaskScheduler going forward.

Seasoned engineer’s take

Background execution is where principled architecture pays off. Lessons:

  1. Cheap, idempotent work is best. Background invocations may be interrupted, repeated, or skipped. Your sync logic must handle that gracefully (cursor + idempotent merge).
  2. Always log what you do in background. OSLog with privacy-correct markers. When users report “the app didn’t refresh,” you need traces.
  3. Respect the system. Apple measures battery impact and throttles abusers. Earn your background time by being lean and useful.
  4. Defer to push when you can. Silent push is more reliable than BGAppRefresh for “something changed on the server.” Save BGAppRefresh for housekeeping.
  5. Test on real devices, in real conditions. Simulator doesn’t reflect iOS’s actual scheduling. Use TestFlight + telemetry to confirm your background tasks actually run.

TIP: When chaining BGAppRefreshTask, always schedule the next one first, before doing the work. If your work crashes, at least the system still knows you want to be called again.

WARNING: Modifying SwiftUI views or UIKit UI from a background task handler will crash. UI updates must come from the main actor. await MainActor.run { ... } or @MainActor annotate the relevant code.

Interview corner

Junior-level: “What’s the difference between viewWillDisappear and applicationDidEnterBackground?”

viewWillDisappear is per-VC (the view is being removed from screen — could be a push, modal dismiss, tab switch). applicationDidEnterBackground (now sceneDidEnterBackground with scene API) is app-level (the user backgrounded the app). Different cleanup work belongs in each — release expensive per-screen resources in the former, save state and snapshot in the latter.

Mid-level: “How would you implement ‘sync user’s data periodically when the app isn’t open’?”

Register a BGAppRefreshTaskRequest with identifier com.app.sync, earliestBeginDate 1 hour out. Handler: schedule next, kick off async sync, on completion call task.setTaskCompleted(success:). Expiration handler cancels the sync Task. Combined with silent pushes for time-sensitive updates. Test by triggering manually via private LLDB call in debug builds and via TestFlight in production.

Senior-level: “Design a podcast app that downloads episodes overnight on Wi-Fi while charging, plays them with lock-screen controls, and handles network changes gracefully.”

Downloads: BGProcessingTaskRequest with requiresExternalPower = true and requiresNetworkConnectivity = true. Handler uses a background URLSession to download episode files. Persistence: track download state per episode in Core Data; URLSessionDelegate updates progress + final URL. App-foreground rebinds the background session to continue progress UI. Playback: AVAudioSession .playback category; MPNowPlayingInfoCenter for lock-screen UI; MPRemoteCommandCenter for play/pause/skip. Audio interruption (call, alarm) handled via AVAudioSession.interruptionNotification. Connectivity changes via NWPathMonitor; if user switches from Wi-Fi to cellular mid-download, pause if “Wi-Fi only downloads” preference set. Energy: skip pre-fetching in Low Power Mode. Telemetry: log background task start/end with episode IDs, processing time, failures — surface in dashboards.

Red flag in candidates: Trying to keep the app “alive” via abuse of background audio (silent audio loop) or location (no real location use case). Apple rejects these and users uninstall battery-drainers.

Lab preview

Background work doesn’t feature in Phase 4 labs directly (UIKit fundamentals focus); covered with real implementation in Phase 6 (SwiftUI + Combine) and Phase 11 (production app).


Next: 4.10 — UIKit + Combine