Build1 publisher3 min readPublished
React Native uploads need a database row and a native session to outlive the lock screen
A suspended app takes the fetch promise with it and comes back with a fresh JS runtime, so the only durable parts of an upload are the row you wrote before it started and the server state you reconcile against.
The Engineer · Build desk

What happened
- A fetch() upload does not reject when the OS suspends the app: there is no error and no catch, and the returning app runs a fresh JS runtime in which the promise never settles.
- The reproduction takes four steps, start a large upload, swipe to home, lock the phone, wait 60 seconds and reopen, and most implementations come back to a progress bar frozen forever.
- The dev.to walkthrough replaces that with a persisted upload record and state machine, hands the transfer to a native background session, and reconciles against the server on return, in about 150 lines.
- expo/fetch does not support background sessions, and FileSystem.uploadAsync, which did, is deprecated as of SDK 54 with known failures on larger files, so background transfer means a native module.
- On Android 14 and below, Service.onTimeout does not exist, so the app is simply cached with no callback and no signal that its window has ended.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint The server, not the client, has to be the authority on whether bytes landed, which means the status endpoint keyed to a client-generated id is a prerequisite for the mobile code rather than a follow-up ticket.
- decision Since SDK 55 is New Architecture only, the choice of background upload module now decides whether you can move off SDK 54 at all.
- cost Tying the background window to a user tap pushes the cost of failed transfers onto the next session: automatic retries cannot claim the same guarantee, so recovery waits until the user opens the app again.
Six state names carry most of the design: pending, uploading, uploaded, processing, ready, failed [5]. Comments in the store matter more than the names. `uploaded` means the bytes are on the server; `processing` means the server is transcoding [5]. The recovery query, `unfinished()`, selects rows in pending, uploading, uploaded or processing, which is four of the six, leaving ready and failed as the only terminal states [6][13]. Two of those four describe work the client is not performing [14]. The resume path is not a retry loop but a question put to the server about an id the client wrote down before the transfer began [18].
That asymmetry exists because the client has no way to learn it lost. iOS suspends the process, Android may reclaim it, and neither OS lets JavaScript keep a transfer alive [3]. The promise never settles: no rejection, no catch, and a fresh runtime when the user returns [1]. A frozen progress bar is the most honest widget in the app, since it reports exactly what the JS runtime knows, and the runtime knows nothing [4].
The handoff to native is where the config lines prove the point. `path` is `localUri.replace('file://', '')`, because iOS wants a bare path [12], and the payload has to be written to a file with a normalised path first, since iOS needs a URL it can still read after your process is gone [7]. The notification block is load-bearing rather than cosmetic: enabling it is what ties the Android transfer to a dataSync foreground service [12].
That service's window comes with a condition. The dev.to walkthrough cites Google's guidance that a dataSync service started from direct user interaction gets the full window once the app backgrounds, and that the budget resets when the user next foregrounds the app [8]. Read it as a constraint on retries: an automatic retry fired while the app is already backgrounded has no such guarantee, so retry scheduling belongs on the next foreground pass [15]. On Android 14 and below there is no timeout callback to hang recovery off in any case, which leaves the foreground reconcile as the only trigger worth trusting [19].
The source is a code walkthrough with a reproduction recipe, roughly 150 lines, not telemetry on how often suspension eats an upload in production [17][2]. For the pattern to transfer to your stack, your server must report per-asset status for an id the client generated, because `asset_id` is nullable at insert time and the client holds only its own primary key until the server answers [18], and your upload URL must survive the gap, because the schema stores `upload_url` and replays it on resume, though the walkthrough does not address presigned expiry [16].
If your API only reports success at the moment the PUT returns, the reconcile step has nothing to ask, and the persisted row is decoration.
What to watch
- A new Expo API that reinstates background session uploads would take the native module out of this design entirely.
- If onTimeout callbacks become dependable on shipping Android versions, timeout-driven recovery stops being off limits.
- Any change to Google's dataSync rule about user-initiated starts would change where the upload button is allowed to live.