Mobile Computing · Background Execution
Handing a Transfer to a System Daemon So It Outlives the App
Why background uploads and downloads are owned by a system process rather than the app, what the delegate relaunch guarantees, and where the handoff loses data.
A transfer is the background workload that least deserves to run inside the app process. It is long, it is mostly waiting, and its failure mode under suspension is total: a socket held by a frozen process stops making progress, and the bytes already moved are worth nothing unless something recorded how far they got.
The systems that handle this well do not try to keep the app alive. They move the transfer out of the app entirely and leave the app with a receipt. The design question is then not how to survive suspension but what the receipt guarantees.
Ownership moves to a process that is not the app
On Apple’s platforms, a background URLSession is served by a system daemon. The app configures the session with a unique identifier, enqueues tasks, and is free to be suspended or killed. The daemon holds the connection, writes the response to a file in a system-managed directory, and records the outcome against the session identifier. Nothing about that pipeline depends on the app’s heap.
When a task completes — or fails, or needs authentication — the system launches the app in the background if it is not running and delivers the event to the session delegate. The launch is a real process launch: the app’s entry point runs, the delegate object is constructed, and the event is dispatched. The app does not replay the transfer; it receives the result.
Android’s equivalent splits along different lines. DownloadManager is a system service that performs HTTP downloads on the app’s behalf, writing to a destination the app chooses and notifying via a broadcast or a content observer. For general work, JobScheduler lets a job be declared with a required network type and a persisted flag, and the system runs it in the app’s process when conditions are met — the job survives reboot if persisted, but the execution is still the app’s own code in the app’s own process. The two are not the same guarantee, and choosing between them is choosing between “the system does the transfer” and “the system decides when the app does the transfer”.
What the daemon writes down
The state the daemon keeps is deliberately minimal, and its shape explains the API’s constraints.
For an upload, the daemon needs a body it can read after the app is gone. A task created from a file URL satisfies that; a task created from a Data object in memory does not, because the bytes would have to be copied into the daemon at creation time and the daemon has no reason to hold an arbitrary blob. The documented consequence is that background uploads are file-based. An app that has the payload only in memory must write it to a file first, and that write is the persistence boundary from Durable Deferred Work and the Persistence Boundary, applied to bytes rather than to a job description.
For a download, the daemon writes the response body to a temporary file and hands the app a URL to it. The file lives outside the app’s container, in a directory the system manages, and it is the app’s responsibility to move it somewhere durable before the delegate returns. An app that reads the file lazily, or that stores the URL and reads it later, finds the file gone: the system reclaims those temporaries when the session is invalidated or when the app’s session state is discarded.
The receipt is therefore not “the data is yours”. It is “the data existed at a known path at the moment of the callback”.
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
// `location` is valid only for the duration of this call.
// Move it into the app container before returning.
let container = FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask
)[0]
let destination = container
.appendingPathComponent(downloadTask.taskIdentifier.description)
do {
try FileManager.default.moveItem(at: location, to: destination)
recordCompletedDownload(at: destination, for: downloadTask)
} catch {
recordFailedDownload(for: downloadTask, error: error)
}
}
The ordering in that function is the whole contract. Move first, record second, and never return before the move has happened.
Relaunch is a race with the app’s own startup
The delegate callback arrives on a process that may have been launched for exactly this purpose. Two things can go wrong that do not exist when the app is already running.
The first is that the delegate is not installed in time. The system delivers the event to the session whose identifier matches, and the session exists only if the app created it during launch with the same identifier and the same delegate. An app that creates its background session lazily, on first use, never receives the event, because the launch that would have delivered it finds no session to deliver it to. The session must be created unconditionally during launch, before the app finishes starting.
The second is that the launch itself is interrupted. A delegate method that performs a long operation on the main thread risks the watchdog, which terminates apps that do not become responsive within a bounded time after launch. Work that must happen in the callback should be a file move and a database write; anything longer belongs in a durable queue that a subsequent grant will drain.
There is a third, subtler race involving completion handlers. If the app is killed after the daemon finished the transfer but before the app recorded it, the next launch finds a completed task and no local record. The daemon’s own record is not a general-purpose log the app can query; it is state the session uses to decide what to deliver. The app must therefore be able to reconstruct the outcome from its own records plus the task identifier, and it must treat a task identifier it has no record of as an orphan whose result can be discarded — the same reasoning that applies when a suspended process returns to find work it scheduled has already happened, described in Process Suspension and the Background Execution Grant.
Resumption is a protocol property, not an API property
The API offers to resume a download, but what resumption actually does is send an HTTP range request for the remainder. That only works if the server supports it, which is a property of the origin server, not of the client library.
A server that answers a range request correctly returns 206 Partial Content with a Content-Range header describing the returned span, and RFC 9110 specifies both. A server that does not support ranges returns the full entity with 200 OK, and the client must then either discard the partial file or handle the mismatch. A server behind a cache or a content delivery network may support ranges for some objects and not others, and the difference is invisible until a resume is attempted.
The other precondition is that the entity has not changed. If the object was modified since the partial download began, resuming would splice two different versions together. The standard defence is a validator: an ETag or a Last-Modified value sent with the range request via If-Range, so that the server returns the full entity rather than a partial one when the validator no longer matches. Without that check, a resumed download can produce a file that is the right length and the wrong content.
Uploads have no equivalent. A partially sent request body cannot be resumed at the HTTP layer; the connection either carried the whole body or it did not. Resumable upload protocols exist, but they are application-level constructs — chunked endpoints with server-side offsets — and they cost a round trip per chunk plus server-side state. The cheap approximation is to make the upload idempotent and retry from the beginning, which is acceptable for small bodies and wasteful for large ones.
Discretionary scheduling and the cost of asking for it
Apple’s background sessions accept a discretionary flag. A discretionary task is one the system may defer until conditions are favourable: on a power source, on Wi-Fi, at a time when the radio is already active. The flag is a permission to be delayed, and its effect is visible in when the completion callback arrives, not in whether it arrives.
Discretionary work is right for prefetching, cache warming, and log upload. It is wrong for a transfer the user is waiting on, because the deferral is unbounded and the user has no way to distinguish “slow network” from “deferred until tonight”.
Android’s JobScheduler expresses the same idea as explicit constraints: unmetered network, charging, device idle, and a minimum latency. The constraint set is checked at execution time, and a job whose constraints stop being satisfied after it starts can be stopped. A stopped job is not failed; it is rescheduled, and the app’s code must cope with being interrupted at an arbitrary point, which is the same idempotency requirement in a different costume.
Where the handoff loses work
Four losses are common enough to be worth naming.
A background session is invalidated with an error when the app is force-stopped by the user, and the pending tasks are cancelled. Nothing is delivered, and no callback runs. Force-stop is outside the model; the app cannot ask for it to be treated gently.
A session that is never invalidated keeps its state alive, and the system may eventually reclaim it. The documented discipline is to invalidate the session explicitly once its tasks have all been delivered, which requires tracking outstanding tasks rather than assuming the session is free.
A task created while the app is in the background may be rejected outright on Apple’s platforms. Enqueueing transfers is a foreground operation; the background session is for carrying them out, not for starting new ones after the app has stopped mattering.
Finally, the destination volume can fill. A download that completes into a full disk produces a move failure, and the failure arrives at the same place as a network error but with a different remedy. Distinguishing them at the point of the move is the difference between retrying later and telling the user to free space.
References
- Apple Developer Documentation — Downloading files in the background — Apple Inc.
- Android Developers — DownloadManager — Android Developers
- RFC 9110: HTTP Semantics — IETF
- Android Developers — JobScheduler — Android Developers