Background Photo Uploads That Survive the App Being Killed
The uploads that quietly disappeared
At Jio Health, a patient with a rash opens the app, taps "add photos," snaps three pictures of their arm, attaches a lab PDF from last week, and sends it all to a doctor before a consultation. So when support started forwarding messages like "I sent the photos but the doctor says nothing arrived," it wasn't a cosmetic bug. It was care not happening.
It never reproduced on my desk. On WiFi, on a charged phone, with the app open, everything worked. The failures were elsewhere: patients on 3G in a clinic waiting room, patients who tapped "send" and immediately locked their phone, patients who got a phone call while the upload ran. Exactly the conditions our users live in.
We had built a normal uploader. A Dart HttpClient streaming a multipart request, a progress bar bound to bytes sent, a checkmark at the end. Correct code, but it assumed the app would stay alive long enough to finish, which on a mobile device is not a safe assumption.
Why a normal upload dies
Your process belongs to the OS, not to you, and it gets suspended or killed whenever the OS decides the user has moved on.
The moment a patient locks the screen or swipes to another app, iOS gives you a few seconds and then suspends your process. Suspended means frozen: your sockets aren't serviced, your Dart timers don't fire, and your upload's TCP connection sits there until it's torn down. Android is more generous, but once your app isn't foreground it can be killed the instant the system wants memory, and a phone under memory pressure kills backgrounded apps constantly.
Our uploader lived inside that process. Suspend the app and the running HttpClient request died with it. Kill the app and the whole upload queue, which lived in memory, was gone. The patient saw a spinner freeze at 40 percent, gave up, and locked their phone, which guaranteed the upload would never resume.
Asking for more background time doesn't fix it: beginBackgroundTask on iOS buys maybe thirty seconds, and a lab PDF over rural 3G takes longer. The upload has to outlive the app, owned by something that keeps running after our process is gone.
Both platforms give you that, but you have to leave Dart to reach it.
Handing the upload to the OS
On iOS, the answer is a background URLSession. You configure a session with URLSessionConfiguration.background(withIdentifier:), hand it an upload task backed by a file on disk, and the system daemon takes over, running the transfer in a separate process. Your app can be suspended, killed, or relaunched and the upload keeps going. When it finishes, iOS relaunches your app in the background just to deliver the completion callback.
On Android, the equivalent is WorkManager. You enqueue a worker with a network constraint, and the system schedules it and runs it whether or not your UI process is alive. It survives swiping the app away from recents, and it survives a device restart.
Neither speaks Flutter, and the whole point is what happens when the Flutter engine isn't running. So I wrote a thin bridge over platform channels on each side, with almost no logic in it.
The Dart side stays small:
final id = await uploads.enqueue(
filePath: compressedPath,
uploadUrl: signedUrl,
headers: authHeaders,
metadata: {'attachmentId': localId},
);
// Dart's job ends here. The OS owns the bytes now.enqueue sends a method call across a MethodChannel. The iOS side creates a background URLSession upload task from the file; the Android side enqueues a WorkManager request. From that instant Dart is done pushing bytes.
One rule: background transfers must be backed by a file, never a buffer in memory. The OS needs a real file on disk it can read after our process is gone, so the pipeline writes a temp file first and enqueues a path.
The queue that survives a relaunch
Handing uploads to the OS solved the transport and created a bookkeeping problem. After a kill and relaunch, how does the app know whether upload #8172 for "arm rash photo 2" is still in flight, failed, or done? The native task list and our idea of the world had to be reconciled every launch.
So the source of truth moved out of memory and into a persistent queue: a small SQLite table, written before we ever touch the network. Each row is one attachment: local id, file path, target URL, state (pending, uploading, done, failed), attempt count, and the native task identifier once we have one.
The lifecycle:
- On enqueue: write the row as `pending`, compress the file, hand it to the native layer, store the returned task id, flip the row to `uploading`.
- On app launch: before showing anything, ask the native side for its list of live background tasks, then walk the queue. Rows the OS still knows about get reattached. Rows marked `uploading` that the OS has never heard of are orphans, finished or dead while we were gone, and get reconciled against the server.
- On completion callback: update the row to `done` or `failed` and notify the UI if it's open.
Reconciling on launch is what made the feature trustworthy. A patient could send photos, quit the app out of habit, come back an hour later, and see the correct final state.
The server had to be idempotent too. A killed app might not have recorded that an upload succeeded, so we sometimes enqueue something that already completed. Each attachment carries an id generated on the client, and the server treats a repeat as the same upload rather than a duplicate. Otherwise reconciliation would have created two copies of every photo.
Flaky networks: chunks, resume, backoff
Owning the upload is half the battle when the network drops every ninety seconds. One request carrying a 6 MB lab scan over unstable 3G will fail, and failing at byte 5.8 million only to restart from zero burns a patient's mobile data.
Two things helped. First, compression. A single photo could be 8 to 12 MB and patients often attached four of them. We resized and recompressed, capping the long edge around 2000 pixels and targeting a JPEG quality that kept text on a lab report readable. That turned a 40 MB batch into something under 5 MB, which matters on a Vietnamese mobile data plan.
Second, resumable chunked transfers. Larger files were cut into chunks the server could accept independently, so a dropped connection cost us the current chunk instead of the whole file. Retries used exponential backoff with jitter: a couple of seconds, then doubling, capped, with enough randomness that a waiting room reconnecting at once didn't stampede the server. WorkManager gives you backoff for free; on iOS the background session's retry behavior plus our queue's attempt count covered it.
The result: an upload that used to fail outright now just took longer, moving chunk by chunk across three network drops, often finishing while the patient was already talking to the doctor.
Callbacks that fire into a dead engine
The last problem is specific to bridging native background work back into Flutter. When a background URLSession upload completes, iOS relaunches your app and calls a delegate method, but there is no Flutter engine running. No widget tree, no isolate, nothing listening on the other end of your MethodChannel.
If you try to call Dart from that callback, you either crash or silently drop the event. I lost a day to uploads that the server confirmed had arrived but that the app insisted were still uploading, because the completion event had nowhere to land.
The fix was to stop treating native and Dart as one continuous conversation. On a background completion, the native side writes the result straight to shared persistent storage, the same store our queue reads, and only then posts an event over the channel if an engine happens to be alive. If the engine is dead, the write is still durable and the reconciliation pass on next launch picks it up. Native completion always persists to disk; the channel event is only an optimization for when the UI is watching. Once the disk was the truth, the "completed but the app didn't notice" bugs disappeared.
What the UI finally showed
Each attachment showed a real state: a determinate progress ring while uploading, a green check on done, and a tappable "retry" on failed that queued the same durable row again. That UI was rendered from the persistent queue, not from live upload events, so it was correct on the first frame after a cold relaunch, before any callback could fire.
The support messages stopped. Not because the network got better, patients were still on the same 3G in the same waiting rooms, but because the upload no longer depended on them watching a spinner. They could send, lock the phone, walk away, and trust it.
Key Takeaways
- Your process is borrowed: on mobile the OS suspends and kills you at will, so any upload that must survive backgrounding cannot live inside your app process.
- Let the OS own the bytes: iOS background `URLSession` and Android `WorkManager` run transfers outside your app; bridge them thinly over platform channels and keep logic out of the bridge.
- Persist the queue, not the progress: a durable queue on disk that you reconcile on every launch is what remembers state across a kill, and idempotent uploads keyed by a client id keep reconciliation from duplicating files.
- Design for the bad network first: compress before sending, chunk large files so a drop is cheap, and retry with exponential backoff and jitter; the failure path is the normal path.
- Native completion is the truth, the channel is a bonus: write results to disk from the background callback because the Flutter engine may be dead, and treat any channel event as an optimization for a live UI, never the system of record.