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 — effectiveType reports 3g, or navigator.connection.saveData is 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

  1. A browser with createImageBitmap, OffscreenCanvas.convertToBlob() and CompressionStream — Chrome 80+, Firefox 105+, Safari 16.4+.
  2. TypeScript with "lib": ["DOM", "DOM.Iterable", "ES2022"], or plain ESM JavaScript with the type annotations stripped.
  3. An endpoint that accepts a raw binary body (Content-Type: image/jpeg, application/octet-stream) — not only multipart/form-data.
  4. CORS on that endpoint allowing PUT, plus the Idempotency-Key and X-Upload-Total-Parts request headers in Access-Control-Allow-Headers.
  5. 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.

Wire size of one 12 MP capture under four encoding pipelines A horizontal bar chart comparing four payloads for the same photo: the original HEIC at 4.20 megabytes, the same file Base64-encoded in JSON at 5.59 megabytes, a canvas-resized JPEG at 0.31 megabytes, and that resized JPEG Base64-encoded at 0.41 megabytes. One 12 MP photo, four ways to put it on the wire Original HEIC, 12 MP Same file Base64 in JSON Canvas 1600 px, JPEG 0.82 Resized, then Base64 4.20 MB 5.59 MB 0.31 MB 0.41 MB Resizing removes 93% of the bytes. Base64 then adds 33% to whatever is left. On a 1.5 Mbps uplink that is 30 s versus 1.7 s per photo.
Pixel reduction dominates every other lever; encoding choice is the smaller, but entirely free, second win.

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.

Choosing a payload strategy by file type A decision tree branching from a file picked on the device into three paths: images are resized on a canvas, videos are re-encoded with WebCodecs or sent as parts, and text payloads are gzipped with CompressionStream. All three branches share the rule that bytes are sent as a Blob rather than Base64. File picked on device Still image HEIC · JPEG · PNG Video MP4 · MOV · WebM Text payload JSON · CSV · SVG Canvas resize, 1600 px JPEG quality 0.82 about 93% fewer bytes WebCodecs re-encode, else ship as 5 MB parts never route through canvas CompressionStream gzip Content-Encoding: gzip about 75% fewer bytes Every branch ends the same way: send a Blob body, never a Base64 string
The branch you take is decided by whether the bytes are already entropy-coded — not by file size.

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.

Peak JavaScript heap for a 40 MB file, data URL versus sliced Blob A stacked column shows the data URL path holding a 40 megabyte ArrayBuffer, a 106 megabyte UTF-16 Base64 string and a 53 megabyte request body copy, totalling about 199 megabytes, next to a sliced Blob path that keeps only a 2 megabyte chunk resident. Peak JS heap for one 40 MB file, two code paths 200 MB 100 MB 0 readAsDataURL() path ArrayBuffer 40 MB Base64 string UTF-16, 2 bytes per char 106 MB Request body copy 53 MB slice() + fetch path One 2 MB chunk in flight Blob slices stay disk-backed until fetch reads them peak about 199 MB peak about 6 MB, flat at any file size
The data URL path scales with file size; the sliced path is constant, which is the property you actually need on a phone.

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.

Mobile chunk retry with IndexedDB resume A 2 megabyte chunk is sliced, sent through an AbortController-guarded fetch, retried with backoff on failure, and its ETag persisted to IndexedDB so completed chunks are skipped on resume. slice() 2MB chunk fetch + abort dynamic timeout server 2xx? IndexedDB save ETag on failure: backoff + jitter, retry same chunk resume skips done
Each chunk is guarded by an abort timeout; failures back off and retry, while successful ETags persist so a resumed upload skips finished work.

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.