Optimizing Payload Size for Mobile Uploads
Send binary, not text, and shrink the pixels before you shrink the bitrate: a 12 MP phone capture that arrives as a 5.59 MB Base64 string in JSON becomes a 311 KB image/jpeg Blob after one OffscreenCanvas pass, and that single change removes 94% of the bytes, most of the upload latency and all of the main-thread stall.
This article sits under Base64 vs binary encoding, inside upload fundamentals and browser APIs. The parent guide covers why the 4/3 ratio exists; this one is the mobile-specific playbook: what to encode, how large to make each request, and how to survive a radio handoff halfway through.
When to use this approach
- Your users upload straight from a phone camera roll, where a single frame is 3–6 MB before anyone touches it and a burst of ten is a 40 MB session.
- You are on a metered or unreliable link —
effectiveTypereports3g, ornavigator.connection.saveDatais set — and every avoidable byte costs money and retry risk. - You control the client. If the bytes must pass through a third-party form or a webhook that only accepts JSON, read the parent guide’s section on when text encoding still wins instead; resizing still helps, but you cannot drop the 33% Base64 tax.
Do not reach for this if you are moving originals for archival or forensic reasons. Re-encoding is lossy and irreversible; in that case keep the original bytes and spend your effort on chunking, as in multipart vs single-PUT for files under 100MB.
Prerequisites
- A browser with
createImageBitmap,OffscreenCanvas.convertToBlob()andCompressionStream— Chrome 80+, Firefox 105+, Safari 16.4+. - TypeScript with
"lib": ["DOM", "DOM.Iterable", "ES2022"], or plain ESM JavaScript with the type annotations stripped. - An endpoint that accepts a raw binary body (
Content-Type: image/jpeg,application/octet-stream) — not onlymultipart/form-data. - CORS on that endpoint allowing
PUT, plus theIdempotency-KeyandX-Upload-Total-Partsrequest headers inAccess-Control-Allow-Headers. - A body-size ceiling you actually know at every hop: browser, CDN, reverse proxy, framework, function runtime.
Where the bytes actually go
Take one frame from a recent Android flagship: 4032 × 3024, HEIC, 4.20 MB on disk. Four plausible pipelines produce wildly different wire sizes for the same picture.
Two things follow from those numbers. First, arguing about multipart/form-data boundary overhead is noise — a boundary costs about 200 bytes per part, four orders of magnitude below the resize win. Second, the worst pipeline is not “binary but unresized”; it is “resized but Base64”, because teams that add compression often add JSON wrapping at the same time and then wonder why the saving is only 90% instead of 93%.
The uplink is the constraint that makes this urgent. A typical LTE handset gets 1–5 Mbps up, roughly a tenth of its download speed, and carrier-grade NAT rebinding on a cell handoff will silently drop an idle TCP connection after 30–120 seconds. A 4.2 MB request that takes 25 seconds is not merely slow, it is statistically likely to die.
Choosing a payload strategy per file type
There is no single answer, because the right move depends on whether the bytes are already compressed. Re-encoding an H.264 MP4 through a canvas is not just wasteful, it is impossible; gzipping a JPEG typically adds 40–80 bytes of deflate header for zero benefit.
The image branch is the one worth automating first, because it is where the ratio is extreme. Canvas resizing is covered end to end in resizing images in the browser with Canvas; the video branch, where a canvas is the wrong tool entirely, belongs to compressing video in the browser with WebCodecs. Both are worth reading before you tune the numbers below.
One free byte saving that costs nothing in quality: a canvas round-trip discards EXIF, which on a phone photo is 4–60 KB of thumbnail, GPS coordinates and lens data. If you keep the original bytes you should still remove it deliberately — see stripping EXIF metadata before upload, which treats it as the privacy control it is rather than a size trick.
Keeping peak memory flat
Byte count on the wire is only half the mobile problem. iOS Safari terminates a tab whose JavaScript heap crosses roughly 1–1.5 GB on a 6 GB device, and it does so with no catchable event: your page simply reloads. FileReader.readAsDataURL() is the fastest way to get there, because it holds several full-size copies at once.
A Blob is a handle, not a buffer. Until something reads it, the bytes usually live in the browser’s disk-backed blob store, so file.slice(0, 2 << 20) costs a few hundred bytes of bookkeeping. Handing that slice to fetch as the body lets the network stack pull it in kernel-sized reads. The mechanics of the slicing itself are in slicing large files with Blob.slice.
Implementation
Three pieces: a profile chosen from the live connection, an encoder that shrinks only what can be shrunk, and a sender that ships parts with a per-part timeout and resumes from IndexedDB.
// payload.ts — pick a byte budget from the live radio conditions.
export interface PayloadProfile {
maxEdge: number; // longest image edge, CSS pixels
quality: number; // JPEG encoder quality, 0–1
chunkSize: number; // bytes per request
concurrency: number; // parallel part requests
}
const PROFILES: Record<string, PayloadProfile> = {
'slow-2g': { maxEdge: 800, quality: 0.62, chunkSize: 256 * 1024, concurrency: 1 },
'2g': { maxEdge: 1024, quality: 0.68, chunkSize: 512 * 1024, concurrency: 1 },
'3g': { maxEdge: 1280, quality: 0.74, chunkSize: 1024 * 1024, concurrency: 2 },
'4g': { maxEdge: 1600, quality: 0.82, chunkSize: 5 * 1024 * 1024, concurrency: 3 }
};
interface NetworkInformationLike {
effectiveType?: string;
saveData?: boolean;
}
export function pickProfile(): PayloadProfile {
// Safari and Firefox do not implement the Network Information API at all.
const conn = (navigator as Navigator & { connection?: NetworkInformationLike }).connection;
const base = PROFILES[conn?.effectiveType ?? '4g'] ?? PROFILES['4g'];
if (conn?.saveData === true) {
// Data Saver is an explicit user request: honour it over the measured speed.
return { ...base, maxEdge: Math.min(base.maxEdge, 1024), quality: 0.6 };
}
return base;
}
// encode.ts — shrink pixels, gzip text, leave compressed media alone.
import type { PayloadProfile } from './payload.js';
export async function shrinkImage(file: File, profile: PayloadProfile): Promise<Blob> {
// createImageBitmap decodes off the main thread and applies EXIF orientation.
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
const scale = Math.min(1, profile.maxEdge / Math.max(bitmap.width, bitmap.height));
const width = Math.max(1, Math.round(bitmap.width * scale));
const height = Math.max(1, Math.round(bitmap.height * scale));
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) throw new Error('2d context unavailable on OffscreenCanvas');
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close(); // release the decoded surface before the encoder allocates
const out = await canvas.convertToBlob({ type: 'image/jpeg', quality: profile.quality });
// A 200 px screenshot re-encoded as JPEG can grow. Never ship the larger one.
return out.size < file.size ? out : file;
}
export async function gzipBlob(file: File): Promise<Blob> {
const stream = file.stream().pipeThrough(new CompressionStream('gzip'));
return new Response(stream).blob();
}
export async function buildPayload(
file: File,
profile: PayloadProfile
): Promise<{ body: Blob; contentType: string; contentEncoding?: string }> {
if (file.type.startsWith('image/') && file.type !== 'image/gif') {
const body = await shrinkImage(file, profile);
return { body, contentType: body.type || 'image/jpeg' };
}
if (file.type.startsWith('text/') || file.type === 'application/json') {
return { body: await gzipBlob(file), contentType: file.type, contentEncoding: 'gzip' };
}
// Video, audio, PDF, ZIP: already entropy-coded. Ship the original bytes.
return { body: file, contentType: file.type || 'application/octet-stream' };
}
// send.ts — part-wise transfer with a size-aware timeout and IndexedDB resume.
import type { PayloadProfile } from './payload.js';
const UPLOAD_URL = '/api/v1/upload';
const MAX_ATTEMPTS = 5;
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
function idb<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const open = indexedDB.open('upload-cache', 1);
open.onupgradeneeded = () => open.result.createObjectStore('parts', { keyPath: 'id' });
open.onsuccess = () => resolve(open.result);
open.onerror = () => reject(open.error);
});
}
function markDone(db: IDBDatabase, key: string, etag: string | null): Promise<void> {
const tx = db.transaction('parts', 'readwrite');
tx.objectStore('parts').put({ id: key, etag, at: Date.now() });
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/** Full jitter over an exponential window, capped at 20 s. */
export function backoffMs(attempt: number): number {
const window = Math.min(1000 * 2 ** attempt, 20_000);
return Math.round(window / 2 + Math.random() * (window / 2));
}
/** 15 s floor plus a size allowance: ~2 Mbps when parallel, ~320 kbps when not. */
export function timeoutMs(bytes: number, profile: PayloadProfile): number {
const bytesPerMs = profile.concurrency > 1 ? 250 : 40;
return Math.max(15_000, Math.round(bytes / bytesPerMs) + 5_000);
}
export async function sendInParts(
body: Blob,
uploadId: string,
profile: PayloadProfile,
contentType: string
): Promise<void> {
const db = await openDb();
const total = Math.ceil(body.size / profile.chunkSize) || 1;
for (let index = 0; index < total; index++) {
const key = `${uploadId}:${index}`;
const done = await idb(db.transaction('parts').objectStore('parts').get(key));
if (done) continue;
const start = index * profile.chunkSize;
const part = body.slice(start, Math.min(start + profile.chunkSize, body.size), contentType);
for (let attempt = 0; ; attempt++) {
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(new DOMException('part timed out', 'TimeoutError')),
timeoutMs(part.size, profile)
);
try {
const response = await fetch(`${UPLOAD_URL}/${uploadId}/${index}`, {
method: 'PUT',
body: part,
signal: controller.signal,
headers: {
'Content-Type': contentType,
'Idempotency-Key': key,
'X-Upload-Total-Parts': String(total)
}
});
if (response.ok) {
await markDone(db, key, response.headers.get('ETag'));
break;
}
if (!RETRYABLE_STATUS.has(response.status) || attempt >= MAX_ATTEMPTS) {
throw new Error(`part ${index} failed: HTTP ${response.status}`);
}
const after = Number(response.headers.get('Retry-After'));
await sleep(Number.isFinite(after) && after > 0 ? after * 1000 : backoffMs(attempt));
} catch (error) {
const name = error instanceof Error ? error.name : '';
// TypeError is how fetch surfaces a dropped socket or DNS failure.
const retryable = name === 'TimeoutError' || error instanceof TypeError;
if (!retryable || attempt >= MAX_ATTEMPTS) throw error;
await sleep(backoffMs(attempt));
} finally {
clearTimeout(timer);
}
}
}
}
Line-by-line on the critical parameters
imageOrientation: 'from-image' is not optional on mobile. Phone cameras write the sensor buffer unrotated and record the rotation in EXIF tag 274; without this option your portrait photos arrive sideways in Chrome and upright in Safari, which is the single most common bug report from this pipeline.
{ alpha: false } on the 2D context drops the alpha channel from the backing store. For a JPEG target the alpha is discarded anyway, and the opaque path avoids a per-pixel composite — worth 10–20 ms on a 1600 px canvas and, more importantly, a quarter of the surface memory.
quality: 0.82 is the knee of the curve for photographic content. Below 0.70 you see ringing on high-contrast edges; above 0.88 file size climbs faster than any perceptible gain. The profile table lowers it on slow radios rather than lowering it globally.
bitmap.close() is called before convertToBlob() so the decoded RGBA surface (4032 × 3024 × 4 bytes ≈ 48 MB) is collectable while the encoder allocates its own buffer. Omitting it roughly doubles the transient peak.
controller.abort(new DOMException('part timed out', 'TimeoutError')) gives the rejection a distinguishable name, so a timeout retries while a genuine user cancel — an AbortError from your own cancel button — propagates. The abort-reason argument needs Chrome 98+, Firefox 97+ or Safari 17+; on anything older the rejection is a plain AbortError and the retry branch will not fire. The full pattern, including a shared signal for a whole upload session, is in aborting uploads with AbortController and timeouts.
Idempotency-Key: ${uploadId}:${index} makes the retry safe. A part can succeed on the server and still time out on the client — the response was in flight when the radio dropped — so a blind retry would write it twice. See retrying fetch uploads with idempotency keys for the server-side dedupe table.
body.slice(start, end, contentType) passes the type through so the part Blob carries the right MIME type; a slice with no type argument produces type: "", and some proxies then guess application/octet-stream and reject it against a strict allowlist.
Configuration reference
| Key | Type | Default | Effect |
|---|---|---|---|
maxEdge |
number (px) | 1600 | Longest edge after resize. 1600 covers a retina phone screen at 2×; 1280 halves the bytes again. |
quality |
number 0–1 | 0.82 | JPEG encoder quality. 0.62 on slow-2g costs visible detail but a third of the size. |
chunkSize |
number (bytes) | 5 MiB | Bytes per PUT. Also the S3 multipart floor for non-final parts. |
concurrency |
number | 3 | Parallel part requests. Above 4 on LTE you mostly add queueing delay and retry cost. |
MAX_ATTEMPTS |
number | 5 | Retries per part. With full jitter the worst case is roughly 60 s of backoff. |
RETRYABLE_STATUS |
Set<number> | 408/425/429/5xx | Never include 400, 401, 403, 413 or 415 — those never succeed on retry. |
timeoutMs floor |
number (ms) | 15000 | Guards against aborting a small part during a 10 s DNS stall. |
imageOrientation |
string | from-image |
Applies EXIF rotation at decode time. none reproduces the sideways-photo bug. |
alpha (2D context) |
boolean | false |
Opaque backing store. Set true only if you encode to PNG or WebP with transparency. |
Configuration gotchas
OffscreenCanvas is not defined
Safari shipped OffscreenCanvas in 16.4 and convertToBlob alongside it; iOS 15 and 16.0–16.3 throw ReferenceError: Can't find variable: OffscreenCanvas. Feature-detect and fall back to a detached <canvas> plus the callback form of toBlob:
export async function encodeCanvas(
bitmap: ImageBitmap, width: number, height: number, quality: number
): Promise<Blob> {
if (typeof OffscreenCanvas !== 'undefined') {
const off = new OffscreenCanvas(width, height);
off.getContext('2d', { alpha: false })!.drawImage(bitmap, 0, 0, width, height);
return off.convertToBlob({ type: 'image/jpeg', quality });
}
const el = document.createElement('canvas');
el.width = width;
el.height = height;
el.getContext('2d', { alpha: false })!.drawImage(bitmap, 0, 0, width, height);
return new Promise<Blob>((resolve, reject) => {
el.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob returned null'))), 'image/jpeg', quality);
});
}
InvalidStateError: The source image could not be decoded
createImageBitmap throws this for HEIC/HEIF on every desktop browser and on Android Chrome, because the decoder is a platform codec that only iOS ships. Detect it and skip the resize rather than failing the upload: wrap the shrinkImage call in a try/catch and return the original File on InvalidStateError. If you must normalise HEIC everywhere, do it server-side after ingest.
Access to fetch has been blocked by CORS policy: Request header field idempotency-key is not allowed by Access-Control-Allow-Headers in preflight response
Adding a custom header turns a simple request into a preflighted one. The PUT never leaves the browser and DevTools shows a bare OPTIONS with no response headers. Add Idempotency-Key and X-Upload-Total-Parts to Access-Control-Allow-Headers, and ETag to Access-Control-Expose-Headers — otherwise response.headers.get('ETag') silently returns null and every resumed part re-uploads.
413 Request Entity Too Large on a part that used to fit
Raising chunkSize from 1 MiB to 5 MiB on a 4g profile will cross the default client_max_body_size 1m in Nginx long before it reaches your application. The response body is HTML from the proxy, not JSON from your API, so a naive await response.json() throws SyntaxError: Unexpected token '<' and masks the real cause. Treat 413 as fatal, log the status before parsing, and see handling 413 and 507 errors during uploads for the recovery path.
Verification
Prove the byte budget in the browser before you trust it in production:
import { pickProfile } from './payload.js';
import { buildPayload } from './encode.js';
export async function assertPayloadBudget(file: File, maxBytes = 400 * 1024): Promise<Blob> {
const { body, contentType } = await buildPayload(file, pickProfile());
console.table([{
original: file.size,
payload: body.size,
ratio: (body.size / file.size).toFixed(3),
contentType
}]);
if (body.size > maxBytes) throw new Error(`payload ${body.size} B exceeds budget ${maxBytes} B`);
if (body.size > file.size) throw new Error('payload grew — the encoding branch is wrong');
return body;
}
Then confirm what actually crossed the wire. curl’s size_upload is the byte count of the request body, so it should match body.size exactly — if it is 1.33× larger, something re-encoded to text:
curl -sS -X PUT "https://api.example.com/api/v1/upload/$UPLOAD_ID/0" \
-H 'Content-Type: image/jpeg' \
-H "Idempotency-Key: $UPLOAD_ID:0" \
--data-binary @part-0.bin \
-o /dev/null -w 'status=%{http_code} sent=%{size_upload} time=%{time_total}\n'
Expected output for the 311 KB payload above: status=200 sent=318464 time=0.94. In DevTools, throttle to “Slow 4G”, upload, and read the Size column in the Network panel — it reports transferred bytes, so a JSON row of 5.6 MB against a 4.2 MB source is a Base64 wrapper you missed. For a byte-accurate progress readout during the transfer itself, wrap the body as described in tracking upload progress with a TransformStream.
Verifying resume across a cell handoff
The last thing to check is behaviour when the radio disappears mid-part. Put the phone in a lift, or toggle airplane mode for four seconds mid-upload: the in-flight part should reject with a TypeError, back off, and retry, while every part already recorded in IndexedDB is skipped.
The IndexedDB record is what makes the resume survive a tab kill rather than only a network blip; the schema and eviction rules are covered in persisting upload state in IndexedDB. If your parts land on object storage rather than your own API, the same loop works against per-part presigned URLs and removes a proxy hop entirely — the measured difference is in direct S3 uploads vs proxy uploads.
Frequently Asked Questions
What is a realistic byte budget for a mobile photo upload?
For a user-generated photo that will be displayed at up to 1600 px, 250–400 KB per image is a comfortable target and matches what large social apps ship. Budget per session rather than per file: ten photos at 350 KB is 3.5 MB, which is about 20 seconds on a 1.5 Mbps uplink and is roughly the limit of what a user will wait through without backgrounding the tab.
Should I re-encode HEIC to JPEG on the device or upload it untouched?
Re-encode when you can, because HEIC at 4.2 MB is a full-resolution original and your product almost never needs 12 megapixels. But createImageBitmap can only decode HEIC on iOS, so treat the resize as an optimisation that may fail and always keep the original-file fallback path working.
Is navigator.connection reliable enough to drive quality settings?
It is a hint, not a measurement, and Safari and Firefox do not implement it at all — so the profile lookup must default rather than branch on its presence. Use saveData as a hard signal because the user set it deliberately, and use effectiveType only to pick between profiles you would be happy shipping either way.
How small should each chunk be on a cellular link?
Small enough that losing one is cheap, large enough that per-request overhead stays under a few percent. On LTE, 1–5 MiB is the useful band: at 256 KB you pay TLS and header cost on every part and the progress bar jitters, while above 8 MiB a single handoff can cost you 20 seconds of re-sent bytes. Remember S3 multipart requires at least 5 MiB for every part except the last.
Why does my progress bar jump to 100% and then hang?
fetch resolves when the response headers arrive, and the browser’s own send buffer accepts the body long before the bytes leave the radio, so naive progress tracking finishes early. Count bytes as they are pulled from the request stream rather than trusting request completion, and smooth the resulting estimate as described in showing accurate time-remaining estimates.