TL;DR A fetch() upload dies when the OS suspends your app, and your JS context is recreated with no memory of it. We are going to build an upload that is a persisted record plus a state machine, hand the actual transfer to a native background session, and reconcile against the server when the app comes back. About 150 lines. Versions this was written against: Expo SDK 54 (React Native 0.81) and SDK 55 (React Native 0.83). Relevant because SDK 54 is the last release with legacy architecture support and SDK 55 is New Architecture only, so any native upload library you pick needs New Architecture support. The failure we are fixing 📱 // the version in every tutorial const res = await fetch(uploadUrl, { method: 'PUT', body: file }); setProgress(100); Background the app mid-transfer and this stops existing. No error, no rejection, no catch block. iOS suspends the process; Android may reclaim it. When the user returns, your JS runtime is fresh and that promise never settles. Test it yourself before reading further, because it is more convincing than any explanation: # start a large upload, then: # 1. swipe to home # 2. lock the phone # 3. wait 60s # 4. reopen the app # most implementations show a frozen progress bar forever 1. Know what the platforms will and will not do Neither OS lets JavaScript keep a transfer alive. The constraints are worth internalising because they dictate the design. Platform What continues in the background The catch iOS Background URLSession upload task Must upload from a file on disk. Data/stream bodies are not supported in background sessions. Completion-handler convenience APIs are unavailable; use the delegate API. Android Foreground service (dataSync) Service type required when targeting Android 14+. Android 15 caps dataSync and mediaProcessing at 6 hours per 24 hours, then calls Service.onTimeout(). Past the budget, starting one throws ForegroundServiceStartNotAllowedException. Two consequences fall straight out: Write the body to a file first. Even if your source is already a file, normalise the path, because iOS needs a stable URL it can read after your process is gone. Start the upload from a user tap. Google's guidance is explicit: a dataSync service started from direct user interaction gets the full window once the app backgrounds. The budget resets when the user next foregrounds the app. ⚠️ Note: Service.onTimeout(int, int) does not exist on Android 14 or below. On those versions you get cached with no callback, so do not build recovery logic that depends on the timeout firing. 2. Persist the upload before any bytes move 💾 This is the whole trick. The record outlives the process; the promise does not. // src/uploads/store.ts import * as SQLite from 'expo-sqlite'; export type UploadState = | 'pending' | 'uploading' | 'uploaded' // bytes are on the server | 'processing' // server is transcoding | 'ready' | 'failed'; const db = await SQLite.openDatabaseAsync('uploads.db'); await db.execAsync(` CREATE TABLE IF NOT EXISTS uploads ( id TEXT PRIMARY KEY, local_uri TEXT NOT NULL, upload_url TEXT NOT NULL, asset_id TEXT, state TEXT NOT NULL, bytes_sent INTEGER DEFAULT 0, bytes_total INTEGER DEFAULT 0, updated_at INTEGER NOT NULL ); `); export async function createUpload(row: { id: string; localUri: string; uploadUrl: string; bytesTotal: number; }) { await db.runAsync( `INSERT INTO uploads (id, local_uri, upload_url, state, bytes_total, updated_at) VALUES (?, ?, ?, 'pending', ?, ?)`, [row.id, row.localUri, row.uploadUrl, row.bytesTotal, Date.now()] ); } export async function setState(id: string, state: UploadState, patch: object = {}) { const cols = Object.keys(patch); const setSql = ['state = ?', 'updated_at = ?', ...cols.map(c => `${c} = ?`)].join(', '); await db.runAsync( `UPDATE uploads SET ${setSql} WHERE id = ?`, [state, Date.now(), ...cols.map(c => (patch as any)[c]), id] ); } export async function unfinished() { return db.getAllAsync<{ id: string; asset_id: string | null; state: UploadState }>( `SELECT * FROM uploads WHERE state IN ('pending','uploading','uploaded','processing')` ); } 3. Hand the transfer to a native background session 🚚 expo/fetch is the modern Expo upload path, but it does not support background sessions. (The older FileSystem.uploadAsync did have a background session type; it is legacy, deprecated as of SDK 54, and had known failures on larger files.) So for anything that must survive backgrounding, you go native, either with a community module or your own. // src/uploads/transfer.ts import Upload from 'react-native-background-upload'; import { setState } from './store'; export async function startUpload(row: { id: string; localUri: string; uploadUrl: string; }) { await setState(row.id, 'uploading'); const taskId = await Upload.startUpload({ url: row.uploadUrl, path: row.localUri.replace('file://', ''), // iOS wants a bare path method: 'PUT', type: 'raw', field: 'file', // keeps the transfer alive when the app is backgrounded notification: { enabled: true, autoClear: true, onProgressTitle: 'Uploading video', }, // Android: ties into a dataSync foreground service android: { notificationChannel: 'uploads' }, }); Upload.addListener('progress', taskId, (data) => { setState(row.id, 'uploading', { bytes_sent: Math.round((Number(data.progress) / 100) * 1), }); }); Upload.addListener('completed', taskId, () => setState(row.id, 'uploaded')); Upload.addListener('error', taskId, () => setState(row.id, 'failed')); Upload.addListener('cancelled', taskId, () => setState(row.id, 'failed')); return taskId; } 💡 Tip: check the module's New Architecture status before you commit to it. From React Native 0.82 the opt-out was removed, and Expo SDK 55 (RN 0.83) is New Architecture only. A library that has not migrated is a dead end, not a temporary inconvenience. 4. Reconcile on relaunch, and let the server win 🔄 Every listener you registered is gone after a process kill. So on boot, do not trust local state; ask the server. // src/uploads/reconcile.ts import { unfinished, setState } from './store'; export async function reconcileUploads() { const rows = await unfinished(); for (const row of rows) { if (!row.asset_id) { // never got far enough to have a server-side asset: retry from scratch await setState(row.id, 'pending'); continue; } const res = await fetch(`/api/assets/${row.asset_id}`); if (!res.ok) continue; const asset = await res.json(); // server states -> local states const map: Record<string, any> = { waiting_for_upload: 'pending', uploaded: 'processing', processing: 'processing', ready: 'ready', errored: 'failed', }; await setState(row.id, map[asset.status] ?? 'failed'); } } Call it once, early: // app/_layout.tsx useEffect(() => { reconcileUploads(); }, []); # typical output after a kill mid-upload [reconcile] 2 unfinished uploads [reconcile] a3f1… server says 'processing' -> local 'processing' [reconcile] 91cd… no asset_id -> requeued as 'pending' 5. Stop calling 100 percent "done" ✅ Bytes delivered is not video playable. Keep processing as a real, visible state driven by a webhook or a poll, and only move to ready when the server says the asset is playable. // src/uploads/ui.ts export function label(state: UploadState, pct: number) { switch (state) { case 'pending': return 'Waiting to upload'; case 'uploading': return `Uploading ${pct}%`; case 'uploaded': return 'Upload complete, preparing video'; case 'processing': return 'Preparing video'; case 'ready': return 'Ready'; case 'failed': return 'Upload failed, tap to retry'; } } That one change removes a whole category of support tickets, because "preparing video" is a thing users will wait through and a stuck 100% is not. The test that matters 🧪 1. Airplane mode off, start a 200MB upload 2. Background the app at ~30% 3. Lock the phone for 2 minutes 4. Toggle airplane mode on and off 5. Force-quit the app 6. Reopen Correct behaviour: the upload either resumed in the background or the record came back as pending and requeued. Incorrect behaviour: a progress bar frozen at 30%, or a duplicate asset on the server. What's next Move to a resumable protocol (tus or S3 multipart) so step 4 continues instead of restarting. On cellular this is the difference between a feature that works and one users abandon. Instrument from the server, not the client. Client-reported success rates only count the uploads that already worked, since the failures happen in a process that no longer exists. Count assets that reached ready against assets that were created. If you are on Expo and considering writing the native module yourself, Expo's own engineering post on building a video upload module with Expo Modules is the closest thing to a reference implementation.