●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Getting a 1GB Video Out of a Rork App Without Losing It — TUS, S3 Multipart, and Background Transfers
A production-ready implementation guide for uploading 1GB+ videos and high-resolution photos from Rork apps. Covers the TUS protocol, S3/R2 Multipart Upload, iOS background transfers, and a resumable progress UI — all with working code.
"I'm uploading my video and the progress bar jumps back to zero the moment I step into an elevator." A user who hits that twice is done with your app.
I ran into this firsthand on a photo-sharing app I used to operate. A 200MB video that uploaded cleanly in testing had a 30–40% failure rate in production, specifically among users who "put their phone in a bag and boarded a train." The cause was simple: a naive fetch + FormData single-shot upload clashes head-on with the realities of mobile networks. This guide walks you through the implementation patterns that solve the problem from the root — with code you can ship.
Why "just fetch + FormData" breaks in production
If you design mobile file uploads the same way you'd design a browser upload, you'll walk straight into four walls.
① Network interruptions are the default, not the exception. Train gaps, underground malls, elevators, and handoffs between Wi-Fi and cellular all kill the TCP connection. A single-shot upload has to restart from zero every time. If you're 95% through a 1GB file when it drops, you just wasted 950MB.
② iOS will kill your background process without mercy. Even if the user doesn't force-quit the app, a standard URLSession stops sending after roughly 30 seconds in the background. Unless you reach for BGTaskScheduler or NSURLSession.background, the notion that your upload "keeps going in the background" is a fantasy.
③ Memory constraints are tight.readAsDataURL on a 1GB file will crash. In React Native / Expo, usable foreground memory sits somewhere between 800MB and 1.5GB depending on the device. Loading the full file into memory is an instant loss.
④ Servers have limits too. Most PaaS platforms cap request bodies: Cloudflare Workers at 100MB, Vercel Serverless at 4.5MB by default, Lambda behind API Gateway at 10MB. A single POST isn't going to clear those gates.
The design that addresses all four simultaneously is a resumable + chunked + background-capable upload foundation.
Three options to compare — which one to pick
There are three practical ways to do resumable, chunked transfers. Each has its sweet spot.
TUS protocol (tus.io): An open, HTTP-based standard. Client libraries exist in most languages, and the server side runs as either OSS (tusd) or Supabase Storage / Uppy Companion. Arbitrary chunk sizes, resume, and parallelism are all handled by the spec. The generalist choice.
S3 / R2 Multipart Upload: AWS's official protocol, supported by Cloudflare R2, MinIO, and other S3-compatible storage. Parts range from 5MB to 5GB, can be uploaded in parallel, and the combined object can be up to 5TB. The right call when you're writing directly to cloud storage.
Supabase Storage Resumable Upload: Supabase's storage layer speaks TUS under the hood, so you get resumable upload in a few lines from the JS client. The fastest path if you're already on Supabase.
My personal decision tree is straightforward: Cloudflare R2 as storage → Multipart Upload, Supabase backend → Supabase Storage Resumable, own API server handling files → TUS. When in doubt, work down that list.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You'll gain a resumable upload foundation that handles 1GB videos across network drops and app terminations — the failure mode that breaks user trust
✦You'll learn when to pick TUS vs S3 Multipart Upload vs Cloudflare R2, with production-ready client code for each path
✦You'll be able to ship background uploads with a progress UI that completes even when users leave the app
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Foundation: designing an upload core for progress, cancellation, and resume
Whichever path you pick, the client-side "upload core" can be shared. A thin abstraction that owns three things makes everything downstream easier to build:
UploadTask: a state machine representing one file's upload (idle / uploading / paused / failed / completed)
ChunkScheduler: splits big files into chunks and manages parallelism and retries
ProgressBus: emits progress / failure / completion events to the UI and the persistence layer
Here's a minimal core in React Native + Expo using Zustand. You'll plug the TUS and Multipart implementations shown later into this base.
Why "persist every 1MB"? Writing AsyncStorage on every chunk turns I/O into the bottleneck. Balancing progress granularity and write frequency — roughly 20–30% of your chunk size — is a good heuristic in practice.
Implementation 1: building resumable uploads with TUS
For your own server or Supabase Storage, tus-js-client runs in React Native with light adaptation. Here's an implementation that uploads a 1GB video in 5MB chunks.
// lib/uploadTus.tsimport * as tus from 'tus-js-client';import * as FileSystem from 'expo-file-system';import { useUploadStore } from '../store/uploadStore';// Upload to a TUS server in chunks.// Expected behavior: 5MB POST/PATCH per chunk; can resume after disconnection.export async function uploadWithTus( taskId: string, endpoint: string, fileUri: string, fileName: string, mimeType: string,): Promise<void> { const store = useUploadStore.getState(); const task = store.tasks[taskId]; if (!task) throw new Error(`task not found: ${taskId}`); // Expo cannot hand Blob directly — read the file as a stream const fileInfo = await FileSystem.getInfoAsync(fileUri); if (!fileInfo.exists) throw new Error('file missing'); return new Promise((resolve, reject) => { const upload = new tus.Upload( // Lightweight wrapper for React Native { uri: fileUri, name: fileName, type: mimeType, size: fileInfo.size, } as any, { endpoint, chunkSize: 5 * 1024 * 1024, // 5MB retryDelays: [0, 1000, 3000, 5000, 10000], // exponential backoff metadata: { filename: fileName, filetype: mimeType }, // Progress is driven by the server's Upload-Offset response onProgress: (bytesUploaded) => { store.updateProgress(taskId, bytesUploaded); }, onSuccess: () => { store.setStatus(taskId, 'completed'); resolve(); }, onError: (err) => { store.setStatus(taskId, 'failed', err.message); reject(err); }, // Resume from the previously issued upload URL if present uploadUrl: task.serverUploadUrl, }, ); // On first run, the server issues an upload URL we persist for resume upload.findPreviousUploads().then((previousUploads) => { if (previousUploads.length > 0) { upload.resumeFromPreviousUpload(previousUploads[0]); } store.setStatus(taskId, 'uploading'); upload.start(); }); });}
Expected behavior: when the app returns from background, tus-js-client sends a HEAD request to ask the server "where did I leave off?" and picks up from that offset. If the connection dies at 700MB, the resume starts at byte 700MB + 1.
Expo caveats: pure tus-js-client assumes File / Blob, so in React Native you'll fall back to XMLHttpRequest + FormData internally. Expo SDK 52 and later ship expo-file-system's createUploadTask with partial TUS compatibility — consider the native approach below if you're rolling your own.
Implementation 2: S3 / R2 Multipart Upload with parallel parts
Uploading directly to Cloudflare R2 or AWS S3 has a big advantage: the client bypasses your server and writes straight to storage. Your server only mints pre-signed URLs, keeping bandwidth costs down too.
The flow is three steps:
① Initiate Multipart: server calls CreateMultipartUpload and returns an uploadId
② Upload Parts: client PUTs each part to a pre-signed URL, in parallel
③ Complete Multipart: when every part is done, CompleteMultipartUpload stitches them together
Here's the client core.
// lib/uploadMultipart.tsimport * as FileSystem from 'expo-file-system';import { useUploadStore } from '../store/uploadStore';interface InitiateResponse { uploadId: string; key: string;}interface PartUrl { partNumber: number; url: string;}const PART_SIZE = 5 * 1024 * 1024; // R2/S3 minimum is 5MBconst PARALLELISM = 3; // 3 is the sweet spot on cellular// Coordinates with your API to run a multipart upload.// Expected behavior: 1GB file splits into 200 parts, uploaded 3-at-a-time.export async function uploadWithMultipart( taskId: string, fileUri: string, fileName: string, apiBaseUrl: string, authToken: string,): Promise<void> { const store = useUploadStore.getState(); const task = store.tasks[taskId]; if (!task) throw new Error('task not found'); const fileInfo = await FileSystem.getInfoAsync(fileUri); if (!fileInfo.exists) throw new Error('file missing'); const totalSize = fileInfo.size; const totalParts = Math.ceil(totalSize / PART_SIZE); try { store.setStatus(taskId, 'uploading'); // Step 1: initiate multipart const initRes = await fetch(`${apiBaseUrl}/uploads/initiate`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ fileName, size: totalSize, totalParts }), }); if (!initRes.ok) throw new Error(`initiate failed: ${initRes.status}`); const init = (await initRes.json()) as InitiateResponse; // Persist uploadId for resume on next run store.setStatus(taskId, 'uploading'); // Step 2: get pre-signed URLs for every part const partUrlsRes = await fetch(`${apiBaseUrl}/uploads/part-urls`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ uploadId: init.uploadId, key: init.key, totalParts, }), }); const { parts } = (await partUrlsRes.json()) as { parts: PartUrl[] }; // Step 3: upload in parallel const completedParts: { PartNumber: number; ETag: string }[] = []; let uploadedBytes = task.uploadedBytes || 0; async function uploadPart(part: PartUrl): Promise<void> { const start = (part.partNumber - 1) * PART_SIZE; const end = Math.min(start + PART_SIZE, totalSize); const partSize = end - start; // Read only the byte range for this part const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64, position: start, length: partSize, }); const binary = decodeBase64ToUint8Array(base64); const res = await fetch(part.url, { method: 'PUT', body: binary as any, }); if (!res.ok) throw new Error(`part ${part.partNumber} failed`); const etag = res.headers.get('ETag')?.replace(/"/g, '') || ''; completedParts.push({ PartNumber: part.partNumber, ETag: etag }); uploadedBytes += partSize; store.updateProgress(taskId, uploadedBytes); } // Bounded concurrency across all parts const queue = [...parts]; const workers = Array.from({ length: PARALLELISM }, async () => { while (queue.length > 0) { const next = queue.shift(); if (!next) return; await retryWithBackoff(() => uploadPart(next), 3); } }); await Promise.all(workers); // Step 4: complete completedParts.sort((a, b) => a.PartNumber - b.PartNumber); const completeRes = await fetch(`${apiBaseUrl}/uploads/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ uploadId: init.uploadId, key: init.key, parts: completedParts, }), }); if (!completeRes.ok) throw new Error('complete failed'); store.setStatus(taskId, 'completed'); } catch (e: any) { store.setStatus(taskId, 'failed', e.message); throw e; }}function decodeBase64ToUint8Array(base64: string): Uint8Array { const binString = atob(base64); const bytes = new Uint8Array(binString.length); for (let i = 0; i < binString.length; i++) bytes[i] = binString.charCodeAt(i); return bytes;}async function retryWithBackoff<T>(fn: () => Promise<T>, max: number): Promise<T> { let lastErr: any; for (let i = 0; i < max; i++) { try { return await fn(); } catch (e) { lastErr = e; await new Promise((r) => setTimeout(r, 500 * Math.pow(2, i))); } } throw lastErr;}
Expected behavior: a 1GB file splits into 200 parts of 5MB each, uploaded 3 at a time. If any single part fails, only that part retries — the rest stays untouched. After the final part, the server calls CompleteMultipartUpload and R2 stitches everything into a single object.
Why parallelism of 3? In my measurements, 3 concurrent uploads is the sweet spot over 4G / 5G. Push it past 5 and per-connection throughput drops enough that the total time gets worse. On Wi-Fi you can go 5–6, so reading NetInfo and setting the value dynamically is a nice touch.
iOS background uploads — pairing with BGTaskScheduler
To keep an upload going after the user backgrounds your app, you need BGTaskScheduler or URLSessionConfiguration.background. On Expo, expo-background-fetch + expo-task-manager is the built-in path.
// lib/backgroundUpload.tsimport * as BackgroundFetch from 'expo-background-fetch';import * as TaskManager from 'expo-task-manager';import { useUploadStore } from '../store/uploadStore';import { uploadWithMultipart } from './uploadMultipart';const TASK_NAME = 'rork-resumable-upload';// Background handler picks up any unfinished task and resumes it.// Expected behavior: iOS calls defineTask when it deems it a good time.TaskManager.defineTask(TASK_NAME, async () => { try { await useUploadStore.getState().hydrateFromStorage(); const tasks = useUploadStore.getState().tasks; const pending = Object.values(tasks).filter( (t) => t.status === 'paused' || t.status === 'failed', ); for (const task of pending) { try { await uploadWithMultipart( task.id, task.fileUri, task.fileName, process.env.EXPO_PUBLIC_API_URL!, await getAuthToken(), ); } catch (e) { console.warn('bg upload failed', task.id, e); } } return BackgroundFetch.BackgroundFetchResult.NewData; } catch (e) { return BackgroundFetch.BackgroundFetchResult.Failed; }});export async function registerBackgroundUpload(): Promise<void> { await BackgroundFetch.registerTaskAsync(TASK_NAME, { minimumInterval: 60 * 15, // 15 min (iOS ignores smaller values) stopOnTerminate: false, startOnBoot: true, });}async function getAuthToken(): Promise<string> { // Omitted: pull JWT from expo-secure-store return '';}
Understand what iOS actually guarantees: a BackgroundFetch task runs when iOS decides it's a good time — based on usage patterns, battery level, network conditions. You cannot order the OS to "keep uploading now." minimumInterval is a hint, and actual invocation can be hours later.
When you need genuine real-time background transfers, exposing URLSessionConfiguration.background through a native module is the only real answer. As of April 2026 there's no fully-featured official Expo module, so you'll either write a native wrapper or adopt something like react-native-background-upload.
UX: progress display, cancellation, and error recovery
Solid transfer plumbing isn't enough — if the user can't see what's happening, they churn anyway. Here's a progress UI wired to the Zustand store.
Startup resume confirmation: any task still in uploading when the app was killed should demote to paused on launch and prompt the user before resuming. Auto-resuming over cellular has caused real billing incidents for users on large transfers.
Cleanup on cancel: for S3 Multipart, forgetting AbortMultipartUpload leaves incomplete parts around for 7 days — which you're paying for. On cancel, always call the server's abort endpoint.
Wi-Fi-only mode: expose a setting so users can restrict uploads to Wi-Fi. Check NetInfo.fetch() for connection type and keep tasks in paused while on cellular.
Three failure patterns I've seen take down production deployments, and the fixes.
① Memory leak from un-GC'd Base64 chunks
The Base64 you get back from FileSystem.readAsStringAsync can linger in the JS heap. Reading 200 chunks of a 1GB file produces enough pressure to OOM partway through. Fix: null out the chunk locals explicitly and keep your Promise.all batch size small. My implementation caps memory at 3 concurrent workers × 5MB chunk ≈ 15MB resident.
② Parallelism trap — more isn't always faster
As noted above, pushing concurrency up on mobile can slow things down. In weak 3G/4G signal, I've measured 2 workers beating 5 workers. Read NetInfo's cellularGeneration and dial parallelism dynamically — that's the production-grade answer.
③ Charge interruption — iOS Low Power Mode kills background tasks
Below 20% battery with Low Power Mode enabled, iOS aggressively terminates background work. Detect lowPowerMode via ExpoBattery.getPowerStateAsync and surface a "plug in to keep uploading" hint — it measurably reduces churn on long transfers.
④ Android's background restrictions
Starting with Android 12, you need an explicit foreground service type or the upload gets chopped when the app closes. Use react-native-background-actions with the dataSync type, or invoke WorkManager from a native module. In Expo EAS, add FOREGROUND_SERVICE_DATA_SYNC to app.json's Android permissions.
Every one of these only shows up after tens of thousands of production uploads. Skipping them up front makes retrofitting them later dramatically more expensive.
Server-side essentials — the four endpoints you need
The client code above leans on four server endpoints. To make this guide self-contained, here's a minimal Cloudflare Workers implementation using Hono and the R2 binding. Drop this into your existing API and wire the R2_BUCKET binding in wrangler.toml.
Why R2's native binding plus aws4fetch? R2's Workers binding supports createMultipartUpload / resumeMultipartUpload directly but doesn't expose pre-signed URLs — and you need pre-signed URLs so the client can PUT parts without proxying through your Worker. aws4fetch handles the SigV4 signing for the S3-compatible REST endpoint. The initiate / complete / abort calls go through the binding for speed and simplicity.
Adding auth: wrap these routes with middleware that validates a Bearer token (JWT, Supabase session, whatever you use). Never expose these endpoints unauthenticated — anyone could initiate a multipart upload against your bucket and burn your storage budget.
Testing the upload flow before shipping
Uploads are the category of feature where testing in a good office Wi-Fi environment gives you false confidence. Here's the test matrix I run before every release.
① Network Link Conditioner (iOS) / Chrome DevTools throttling: simulate "Very Bad Network" (1% loss, 500ms RTT). Your upload should still complete for any file size — just slower. Anything less is a bug.
② Airplane mode flip test: start a 200MB upload. At 30% progress, toggle airplane mode on for 10 seconds, then off. The upload should resume automatically within 15 seconds, with progress picking up from the last persisted byte — not zero.
③ Force-quit recovery: start a 500MB upload. At 50% progress, force-quit the app from the app switcher. Relaunch 30 seconds later. The task should appear in paused state with a "Resume" button. Tapping it should continue from roughly where it left off (a few seconds of re-verification is acceptable).
④ Background burst test: start the upload, immediately press home. Wait 20 minutes, then foreground the app. Progress should have advanced (within iOS's scheduling constraints). Verify you see the UploadProgressList reflect updates from the background task on foreground.
⑤ Cancellation leak test: upload 10 files, cancel them at random progress. Then query your storage dashboard — there should be zero orphaned multipart uploads. If you see any, your abort endpoint isn't wired correctly.
⑥ Large-file stress test: upload the largest file your app will realistically handle (say, 2GB of 4K video) on both iOS and Android, Wi-Fi and cellular. Record completion time, failure count, and peak memory. These are the numbers you'll monitor in production.
Automating 1–5 in CI with Maestro or Detox is the ideal end state, though in my experience a manual QA pass covering these scenarios once per release cycle catches 90% of regressions for a fraction of the upfront cost.
Production readiness checklist
Here's what to verify before shipping resumable uploads. The list is long — work through it item by item in the release sprint.
Server: uploadId TTL is set (S3/R2 don't expire multiparts by default — run a cron that calls AbortMultipartUpload after 24h)
Server: part-number bounds validation (S3 rejects outside 1–10,000) is in place
Server: the completed file size matches the client-reported size (anti-tampering)
An upload foundation is the kind of asset you build once and re-use for every future media feature. Behind a single-line support ticket like "my video keeps failing" is a user whose trust you're losing minute by minute. I hope this gives you the concrete building blocks to make that failure mode disappear.
Your first step today: try uploading a 500MB video from your own app while riding a train. Record the failure rate. Once you ship this implementation, you'll have a number to measure the improvement against.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.