Skip to content

6. iOS: The Background URLSession

Doc 5 showed Android keeping an app alive so it can keep feeding the downloader. iOS takes the opposite approach. It barely lets an app run in the background at all, but it offers a system service that downloads on the app’s behalf while the app is suspended. This doc explains how the downloader uses that service, what it changes for the rest of the architecture, and the timeout that used to cut long downloads off.

The model: a system daemon does the transfer

Section titled “The model: a system daemon does the transfer”

background_downloader on iOS creates a background URLSession. Tasks created in that session are carried out by nsurlsessiond, a system process, not by the app:

APP (Flutter + Dart) SYSTEM (nsurlsessiond)
──────────────────── ──────────────────────
enqueueAll(1,804 tasks) ───────────────► every download task created
and resumed immediately
user locks the phone
app is suspended (Dart stops) transfers continue
…
files finish; iOS resumes or
relaunches the app in the
application(_:handleEventsForBackground ◄─────────────────────────────
URLSession:completionHandler:) (handled inside the package,
package recreates the session, which re-creates the session
moves finished files into with the same identifier)
<Documents>/<slug>/…, reports status

From Apple’s documentation: if the app is in the background, the system may suspend it while the download runs in another process. When the download finishes, the system resumes the app and calls application(_:handleEventsForBackgroundURLSession:completionHandler:). If the system terminated the app while it was suspended, it relaunches the app in the background, and the app must recreate the session with the same identifier. The package does both (BDPlugin.application(_:handleEventsForBackgroundURLSession:…) and UrlSessionDelegate.createUrlSession).

Setting Value Set by
Session type URLSessionConfiguration.background(withIdentifier:) package
timeoutIntervalForResource 7 days Config.resourceTimeout in lib/main.dart (package default 4 h)
timeoutIntervalForRequest 60 s Config.requestTimeout in lib/main.dart
httpMaximumConnectionsPerHost 6 (Apple default; HTTP/1.1 only, HTTP/2 multiplexes) not set
isDiscretionary false for transfers started in the foreground not set (see below)
allowsCellularAccess allowed the app never marks tasks Wi-Fi-only (see below)
Backup downloaded files excluded from iCloud backup Config.excludeFromCloudBackup: always

Android feeds WorkManager eight files at a time through a holding queue (doc 5). iOS must not: that queue lives in the app, and on iOS the app is suspended while the phone is locked. Nothing would hand over the ninth file, so a locked phone would download eight segments and stop.

On iOS the whole list goes to nsurlsessiond up front, and the system decides how many run at once. holdingQueue is configured under androidConfig only, so iOS does not get it.

timeoutIntervalForResource is “how long to wait for an entire resource to transfer before giving up”. Apple’s documentation: “The resource timer starts when the request is initiated and counts until either the request completes or this timeout interval is reached.”

Every task is created and resumed at enqueue time, so every segment’s timer starts at the tap. A segment queued behind 1,800 others is still “in progress” for timeout purposes while it waits:

BEFORE (resourceTimeout = 4 h) AFTER (7 days)
21:00 1,804 segments resumed 21:00 1,804 segments resumed
21:00–01:00 ~40% downloaded on a slow link …downloads until done
01:00 every still-queued segment fails
at once with a timeout
retries wait for the app to be opened
three recovery rounds later → "failed"

At 4 hours, any title that took longer than four hours to download could not finish in one go. The value is now 7 days, Apple’s own default. The 60-second request timeout still catches a connection that goes silent.

A new timeout applies to tasks created after the app starts with the new configuration. A title already mid-download from an older build gets the new value when its missing files are next re-queued.

What Dart can and cannot do in the background

Section titled “What Dart can and cannot do in the background”

While the app is suspended, none of the Dart-side machinery runs:

  • no status processing
  • no package retries (they are Dart timers)
  • no stall watchdog
  • no settlement

In practice:

  • Updates that cannot be delivered are kept. If the package cannot post a status update to Dart, it stores it in UserDefaults (under com.bbflight.background_downloader.statusUpdateMap.v2). FileDownloader().start() replays those on the next launch (doc 4, layer 6).
  • Failures wait. A segment that fails while the phone is locked is not retried until Dart runs again. Settlement then re-queues it.
  • Recovery is dependable when the user opens the app. The downloads subsystem initializes after the first rendered frame (doc 7), and _onAppResume recounts the disk and finalizes whatever finished meanwhile. Do not rely on recovery logic running during a background relaunch.
  • Re-queues from the background may wait. Apple: “For transfers started while your app is in the background, the system always starts transfers at its discretion.” It might, for example, wait for Wi-Fi and power. Normal starts, resumes and retries happen with the app in front and are not discretionary.

DownloadProgressNotifier is Android-only and does nothing on iOS. Instead, DownloadManagerImpl._configureGroupNotification asks the package to show one system notification per title (groupNotificationId = the title’s group), with fixed text:

State Title Body
running “Downloading” the title
complete “Download complete” the title
paused “Download paused” the title
error “Download interrupted” “title — open Chorki to finish”
canceled none (the notification is removed)

No progress bar is shown. The configuration is applied:

  • when a download starts, resumes or is retried
  • for each resumed row at cold start
  • again whenever the app language changes

While the app is in the foreground, the package presents these in Notification Center only, without a banner.

Three details look arbitrary but are deliberate:

  • The text never changes during a download. iOS issues a new banner whenever a notification’s text changes, so a percentage would buzz the user for every step.
  • complete is always configured. In package 9.5.5, a group whose complete notification is null has its finished notification removed under the wrong identifier. The stale “Downloading” banner then stays forever.
  • “Interrupted”, not “failed”. A segment that fails while the phone is locked is recovered automatically the next time the app opens, so “failed” on the lock screen would be a false alarm.

The OS can restrict a request to Wi-Fi (allowsCellularAccess = false), and the package does that for tasks marked requiresWiFi. The app does not use it. “Download over Wi-Fi only” is enforced the same way on both platforms: the tap-time check in doc 2 and ConnectivityCubit’s pause/resume in doc 4. Like all Dart logic on iOS, that pause and resume only happens while the app is running.

Event Transfers Recovery
User switches app or locks the phone continue in nsurlsessiond on resume: replayed updates, _onAppResume recount
System terminates the suspended app continue; the app is relaunched in the background when they finish session recreated by the package; full recovery when the user opens the app
A segment fails while suspended that segment stops (retries are Dart timers) re-queued by settlement once the app runs
User swipes the app away from the app switcher not covered by Apple’s background-download guide; verify on a device cold start re-queues whatever is missing on disk
  • lib/main.dart: _initBackgroundDownloader (resourceTimeout, excludeFromCloudBackup, requestTimeout).
  • lib/data/services/download_manager_impl.dart: _configureGroupNotification, _onAppResume, _coldStartReconcile.
  • Package, background_downloader-9.5.5/ios/background_downloader/Sources/background_downloader/:
    • UrlSessionDelegate.swift: createUrlSession.
    • BDPlugin.swift: task creation, handleEventsForBackgroundURLSession, notification presentation.
    • TaskFunctions.swift: storeLocally for updates that could not be delivered.

On iOS the app does not keep a download running; it hands the whole title to a system daemon and gets out of the way. That daemon keeps going while the app is suspended, but everything the app itself decides (retries, recovery, completion) waits until the app runs again. So the iOS rules are:

  • give the system the full list at once
  • set a timeout long enough to span a slow title
  • keep notifications static
  • make every recovery work from disk the moment the app is opened