6. iOS: The Background URLSession
6. iOS: The Background URLSession
Section titled “6. iOS: The Background URLSession”Where we left off
Section titled “Where we left off”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 statusFrom 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).
Configuration
Section titled “Configuration”| 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 |
Why iOS gets every task at once
Section titled “Why iOS gets every task at once”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.
The resource timeout, and the 4-hour wall
Section titled “The resource timeout, and the 4-hour wall”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(undercom.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
_onAppResumerecounts 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.
Notifications on iOS
Section titled “Notifications on iOS”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.
completeis always configured. In package 9.5.5, a group whosecompletenotification isnullhas 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.
Wi-Fi-only on iOS
Section titled “Wi-Fi-only on iOS”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.
What survives what
Section titled “What survives what”| 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 |
Where this lives in the code
Section titled “Where this lives in the code”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:storeLocallyfor updates that could not be delivered.
The takeaway
Section titled “The takeaway”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