TWG SDK / Background Uploader

    iOS + Android · React Native / Expo

    Native upload queue for React Native and Expo

    Uploads that survive your app not being there.

    On iOS the system's own transfer daemon carries the bytes while your app is suspended. On Android a foreground service of ours does. Chunked, resumable, and written to disk, so the outcome reaches your JavaScript even if it was asleep when the upload finished.

    No card, no cost — just click and book

    569

    automated tests behind the two native cores — 298 XCTest, 271 JUnit

    70+

    scenario rows in the published test report, each quoting the device's log

    iPhone

    iOS measured on a physical device: backgrounding, system kill, relaunch, cancel, byte integrity

    Android phone

    Android measured on a physical device and on API 34–37 images: system kill mid-chunk, force-stop, network loss, Doze, renewal, wifi-only on cellular

    Every row quotes the device's own log lines. The report ships with the package and is available on request.

    What survives — and what does not

    Survives

    • Your app going to the background. iOS's daemon keeps transferring; Android's foreground service keeps the process alive, with a notification and a progress bar.

    • The system killing your app. iOS relaunches it and the module re-attaches to the in-flight chunk; Android restarts the service and resumes from the last committed offset — the first new chunk 7–16 seconds after the kill, 7 kills out of 7.

    • The network dropping and coming back. The upload parks, re-syncs the offset, continues. wifiOnly uploads wait for an unmetered network.

    • Presigned URLs expiring mid-upload. You are told the target expired; hand over a fresh one and the upload keeps its id and metadata and re-uploads to the new target, up to three times.

    • Presigned part URLs expiring mid-upload (s3Multipart). You hand over fresh part URLs and the completed parts stay in the bucket; only the parts not yet sent go out again.

    • Your JavaScript never seeing the completion. The outcome is a record on disk, delivered on the next launch, once.

    Does not — and we say so

    • The user force-quitting the app. On iOS the system cancels the background session outright. On Android nothing runs at all, and the resume booking does not survive either. Both resume from the last committed offset at the next launch, and if the user never comes back, the upload waits.

    • Resuming a single-request upload (put, multipartForm). There is no offset to ask the server about, so every retry sends the whole file from zero. On iOS a backgrounded one is routinely delivered twice: the daemon finishes the body, the relaunch cannot tell, and the file goes again. Budget twice the bytes and require an idempotent endpoint.

    • Retrying while suspended on iOS. The daemon holds a transfer already in flight and nothing more. Backoff, re-sync and network recovery are app code, frozen with your process, so the first hard failure ends progress until your app runs again.

    • Renewing an expired target without your app. Presigning is your code, and neither the iOS daemon nor the Android service can mint URLs. An upload that expires while your JavaScript is not running waits in targetExpired for the next launch. Nothing is lost, but nothing moves either.

    • Compressing or transcoding. The module never touches the file. On iOS that work stops when the app suspends, so compress in the foreground, then upload.

    Four protocols, one API

    protocolWhat it sends
    tus

    Resumable and chunked: create a session (or use one you were given), PATCH per chunk, HEAD to re-sync. Default chunk 64 MiB on iOS, 8 MiB on Android — both tunable.

    put

    One PUT (or POST) of the whole file to a presigned URL. No offset to resume from: a failed attempt is retried once, from zero.

    multipartForm

    One multipart/form-data request with the file and your fields. No offset to resume from: a failed attempt is retried once, from zero.

    s3Multipart

    Resumable and chunked to any S3-compatible store (S3, R2, Spaces, B2, Wasabi, MinIO): your backend presigns the parts and the completion, the module sends the parts, keeps each part's ETag on disk and completes the upload itself — with the app dead, if need be. Default part 8 MiB, minimum 5 MiB.

    Works with tus servers (tusd, Cloudflare Stream), S3-compatible object storage (single presigned PUT for small files, multipart for large ones) and plain form endpoints. Anything over ~50 MB wants tus or s3Multipart. Your backend, your URLs; the module never talks to ours.

    The queue

    Priorities and a concurrency limit.

    Pause and resume one upload or all of them; the state is on disk, so it holds across launches.

    Events for progress, state changes, success, error and target expiry; outcomes replayed once after a relaunch and acknowledged by you.

    A native event log you can read from JS — the same log the test report quotes.

    Integration

    upload.ts
    import { startUpload, addUploadListener, getUploads, acknowledgeUpload }
      from '@thewidlarzgroup/background-upload';
    
    const id = await startUpload({
      protocol: 'tus',
      fileUri,                  // file:// on both, content:// on Android
      endpoint: 'https://your-tus-server/files/',
      chunkSize: 8 * 1024 * 1024,
      metadata: { videoId },    // returned by getUploads() and with onTargetExpired
    });
    
    addUploadListener('onSuccess', async ({ id }) => {
      const upload = (await getUploads()).find((u) => u.id === id);
      if (!upload) return;
      await markVideoUploaded(upload.metadata.videoId);
      await acknowledgeUpload(id);   // frees the queue entry
    });
    s3-multipart.ts
    // Your backend did CreateMultipartUpload and presigned the parts + Complete.
    const { uploadId, partUrls, completeUrl, partSize } = await presignMultipart(fileSize);
    
    await startUpload({
      protocol: 's3Multipart',
      partUrls,      // one presigned UploadPart URL per part, in order
      completeUrl,   // presigned CompleteMultipartUpload; the module sends the ETag list
      partSize,      // default 8 MiB, minimum 5 MiB
      uploadId,      // optional; detects a backend that re-created the upload on renewal
      fileUri,
      renewable: true,
      metadata: { assetId },
    });

    Expo Modules API on both platforms. A config plugin on iOS, manifest merging on Android. One npm install, npx expo prebuild, done.

    An example screen is delivered alongside the package: pick a video, upload it under any of the four protocols, watch the native event log grow while the app is in the background. That is the one thing worth seeing with your own eyes before adopting any of this.

    Tested with Expo 54+ / React Native 0.81+. iOS 15.1+. Android: the module inherits your app's minSdk (Expo 54 defaults to 24).

    iOS and Android, side by side

    iOSAndroid
    What keeps the transfer alivethe system's background session daemon; your app is suspendedour foreground service; your process stays alive
    Visible to the usernothingone notification with a progress bar while uploads run, once your app has been granted POST_NOTIFICATIONS; without it the upload still completes, invisibly
    After the system kills the appthe daemon finishes the chunk; a relaunch re-syncsthe service restarts and resumes from the last committed offset
    Completing a multipart upload after a killthe daemon finishes the parts in flight; iOS relaunches the app in the background and it sends Complete itself (row S2b)the service restarts and sends the remaining parts and Complete from the last committed offset
    Chunk boundarya wake-up of your appfree
    Metered networkswifiOnly = not expensivewifiOnly = unmetered

    How we work

    The contract comes before the code

    Both native cores implement one written behavioural contract: states, error codes, events, queue rules, renewal. Where the platforms must differ, the contract says how.

    Evidence, not adjectives

    Every scenario we claim is a row in a test report with the device's own log lines quoted — a physical iPhone, a physical Android phone, and Android 14–17 images.

    Delivered as a package, identified by digest

    A private npm tarball with the native cores as binaries and the Expo shims in source; each delivery pinned by its SHA-256.

    Built for

    A social video app on mobile data

    A social video product whose users are on cellular networks in emerging markets had uploads die the moment the app was backgrounded: the JavaScript runtime does not run while an app is suspended. Their videos are 100–120 MB after on-device compression, sent over tus with presigned URLs straight to their video platform.

    The module now carries them in the background on both platforms, and they tested it against their real editor and backend before adopting it.

    Where it fits

    Social video and community apps

    Large files from phones on unreliable networks.

    Marketplaces and e-commerce

    Listings with many photos and videos, uploaded while the seller moves on.

    Field services and inspections

    Evidence captured on site, delivered when the connection allows.

    What our clients say

    PB
    "Integrating the Background Uploader plugin from TWG saved us weeks of work on building our own solution and testing edge cases. Our top priorities were uninterrupted uploads of large files — even over 500 MB — and support for Cloudflare R2, and both needs were addressed perfectly."

    Piotr Bogacz

    Technical Lead at Selmo

    FS
    "With The Widlarz Group's Background Uploader SDK, video uploads finally work the way they should for our users. A once fragile part of our product has become almost invisible to both customers and our support team, who no longer have to deal with failed uploads."

    Filip Szczepanik

    CEO at Selmo

    Book a technical walkthrough

    Forty minutes with the engineer who built it: your backend, your file sizes, and what the report shows for them.

    No card, no cost.