On Prisium, a remote multi-camera live production platform, every camera can record a high-resolution ISO file — up to 4K — while the live preview runs on a separate WebRTC pipeline. The live feed is for watching; the ISO recording is what the producer actually edits afterwards. If it doesn't arrive intact, the session is effectively lost.
I worked on the 4K ISO recording path end to end: orchestration on the web, native capture in Kotlin and Swift, a dual-pipeline capture on Android, and resumable chunked uploads to Google Cloud Storage with retry. That combination improved recording reliability by 70%. Here is what mattered.
Why a single upload request fails#
A 4K recording is a large file, and phones are a hostile place to upload one. A single long HTTP request has to survive the whole transfer without the network changing, the OS suspending the app, or the user locking the screen. Any one of those kills the request, and a naive upload starts again from byte zero.
The fix is to make the upload resumable: split it into chunks, remember how far you got, and continue from there after any interruption.
Resumable uploads on Google Cloud Storage#
GCS supports resumable uploads natively. The flow is:
- Start a session. Make one request to create a resumable upload; GCS returns a session URI.
- Send chunks.
PUTeach chunk to the session URI with aContent-Rangeheader. Chunk sizes must be multiples of 256 KiB, except the last. - Read the response. A
308response means "keep going" and includes aRangeheader with the bytes GCS has persisted. A200or201means the object is complete. - Recover. After an interruption, ask the session how much it has, then continue from there.
const CHUNK = 256 * 1024 * 32; // 8 MiB — a multiple of 256 KiB
async function uploadFrom(sessionUri: string, file: Blob, offset: number) {
while (offset < file.size) {
const end = Math.min(offset + CHUNK, file.size);
const res = await fetch(sessionUri, {
method: 'PUT',
headers: { 'Content-Range': `bytes ${offset}-${end - 1}/${file.size}` },
body: file.slice(offset, end),
});
if (res.status === 200 || res.status === 201) return; // done
if (res.status !== 308) throw new RetryableError(res.status);
const range = res.headers.get('Range'); // e.g. "bytes=0-8388607"
offset = range ? Number(range.split('-')[1]) + 1 : 0;
}
}The key detail: trust the server's Range, not your own counter. GCS may persist less than you sent, and resuming from your own offset would corrupt the file.
// Ask GCS how much it already has after a crash or network change.
async function persistedOffset(sessionUri: string, size: number) {
const res = await fetch(sessionUri, {
method: 'PUT',
headers: { 'Content-Range': `bytes */${size}` },
});
if (res.status === 200 || res.status === 201) return size;
const range = res.headers.get('Range');
return range ? Number(range.split('-')[1]) + 1 : 0;
}Retries that don't make things worse#
Retrying is easy; retrying well is not. The rules I'd recommend:
- Retry only what's retryable. Network errors,
408,429and5xxare worth retrying. Most other4xxresponses mean the request is wrong and will stay wrong. - Back off exponentially with jitter. Many devices retrying in lockstep after an outage is how you cause a second outage.
- Re-check the offset before every retry. Never assume the failed chunk was lost — query the session first.
async function withRetry<T>(fn: () => Promise<T>, attempts = 6) {
for (let i = 0; ; i++) {
try { return await fn(); }
catch (err) {
if (!isRetryable(err) || i >= attempts - 1) throw err;
const base = Math.min(32_000, 1000 * 2 ** i);
await sleep(base / 2 + Math.random() * base / 2); // jitter
}
}
}What actually breaks on mobile networks#
The chunking logic is the easy half. The field failures are what take time:
- Network handover. Walking from Wi-Fi to cellular drops in-flight requests. Treat it as normal, not exceptional.
- App backgrounding. Both iOS and Android restrict what a backgrounded app can do. Long transfers need the platform's background-transfer facilities, which is one reason capture and upload lived in native Kotlin and Swift modules rather than only in JavaScript.
- Memory pressure. Reading a whole 4K file into memory to slice it will crash on many phones. Stream chunks from disk.
- Storage running out mid-recording. Check free space before recording and handle the write error cleanly, so you keep what was captured.
- Process death. If the OS kills the app, everything in memory is gone. The recording path, session URI and last confirmed offset must be on disk.
The takeaway#
Reliable uploads from phones are less about clever code and more about assuming everything will be interrupted. Persist state on disk, trust the server's view of progress, retry only what's retryable with jittered backoff, and resume instead of restarting. Those habits — plus native capture where JavaScript couldn't do the job — are what moved recording reliability on Prisium by 70%.