Mobile & Camera Capture Uploads

Most uploads now start on a phone, and phones break assumptions a desktop-built uploader never noticed: photos are 12–48 megapixels in a format half the web cannot decode, the camera app can make the operating system kill your tab, memory is a fraction of a laptop’s, and the network changes under the upload several times a minute. A mobile-ready upload flow treats capture, conversion and transfer as one pipeline designed for those constraints, instead of a desktop file input that happens to work on phones sometimes.

This topic belongs to upload fundamentals and browser APIs. It sits beside drag-and-drop file uploads, the desktop acquisition path, and relies on background and offline uploads to survive the app switches that are normal on phones. Once files are acquired, client-side media preprocessing shrinks them before transfer.

Prerequisites

  • [ ] HTTPS on every page with a file input or camera — required for getUserMedia, service workers and most modern file APIs.
  • [ ] Real test devices: one recent iPhone, one mid-range Android with 4 GB of RAM or less. Emulators do not open real camera apps or reproduce memory pressure.
  • [ ] A client-side resize and re-encode step (canvas or OffscreenCanvas), because camera originals are far larger than any product displays.
  • [ ] A resumable or chunked upload endpoint, so a network change costs one chunk rather than the file.
  • [ ] Server-side validation that relies on file contents, not on names or file.type.
  • [ ] Analytics that record device, browser, file size and outcome per upload, so you can see where mobile flows fail.

How it works

A mobile upload moves through four stages, and each has a characteristic failure.

Capture gets bytes from the camera or the photo library. The capture attribute on a file input opens the native camera app with no permission prompt — capturing photos with the capture attribute covers it — while getUserMedia with MediaRecorder records inside the page, as in recording video with MediaRecorder for upload. The characteristic failure is the OS killing the browser while the camera app is open, so the page reloads and the form is empty.

Normalise turns whatever arrived into something predictable: upright pixels, a format everything can read, a sensible size, no location metadata. The characteristic failure is HEIC — the iPhone default — which most non-Apple browsers cannot decode; converting HEIC images to JPEG in the browser handles it.

Transfer sends the bytes. The characteristic failures are network changes (Wi-Fi to cellular, a tunnel) and the tab being frozen when the user switches apps.

Confirm tells the user the file arrived and what happens next. The characteristic failure is silence: an upload that stalled in a frozen tab looks identical to one still in progress.

Mobile upload stages and their characteristic failures Four stages in sequence: capture, normalise, transfer, confirm. Under each is its typical mobile failure: the page reloads after the camera app, HEIC cannot be decoded or memory runs out, the network switches or the tab freezes, and the user gets no feedback about a stalled upload. Four stages, four ways a phone breaks them capture camera / library normalise orient, JPEG, resize transfer chunked, resumable confirm status the user sees page reloads OS killed the tab during camera use HEIC / OOM cannot decode, or 48 MP decode crashes network / freeze Wi-Fi ↔ 4G switch, tab frozen in background silence stalled looks like "still uploading" Each failure has a specific mitigation; together they are what "works on mobile" actually means.
The desktop happy path hides all four failures; each needs a deliberate countermeasure on phones.

Step-by-step implementation

Step 1: Offer both camera and library

Two inputs, one with capture, one without, opened from clearly labelled buttons. Users get the fast camera path and keep access to existing photos.

export function mountPhotoPicker(root: HTMLElement, onFile: (f: File) => void): void {
  root.innerHTML = `
    <button type="button" data-kind="camera">Take photo</button>
    <button type="button" data-kind="library">Choose from library</button>
    <input type="file" accept="image/*" capture="environment" hidden data-input="camera">
    <input type="file" accept="image/*" hidden data-input="library">`;
  root.querySelectorAll<HTMLButtonElement>("button[data-kind]").forEach((b) =>
    b.addEventListener("click", () =>
      root.querySelector<HTMLInputElement>(`input[data-input="${b.dataset.kind}"]`)!.click()));
  root.querySelectorAll<HTMLInputElement>("input[type=file]").forEach((input) =>
    input.addEventListener("change", () => {
      const f = input.files?.[0];
      input.value = "";
      if (f) onFile(f);
    }));
}

Step 2: Save form state before opening the camera

On devices with little memory, the OS may kill the browser while the camera app is foregrounded. Persist whatever the user has typed, so a reload restores it.

const KEY = "upload-form-draft";

export function saveDraft(form: HTMLFormElement): void {
  const data = Object.fromEntries(new FormData(form).entries());
  delete (data as Record<string, unknown>).photo;               // files cannot be stored here
  sessionStorage.setItem(KEY, JSON.stringify({ at: Date.now(), data }));
}

export function restoreDraft(form: HTMLFormElement): boolean {
  const raw = sessionStorage.getItem(KEY);
  if (!raw) return false;
  const { at, data } = JSON.parse(raw) as { at: number; data: Record<string, string> };
  if (Date.now() - at > 30 * 60_000) { sessionStorage.removeItem(KEY); return false; }
  for (const [name, value] of Object.entries(data)) {
    const el = form.elements.namedItem(name);
    if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) el.value = value;
  }
  return true;
}

const form = document.querySelector<HTMLFormElement>("#report")!;
form.querySelectorAll("[data-kind]").forEach((b) => b.addEventListener("click", () => saveDraft(form)));
if (restoreDraft(form)) console.log("draft restored after reload");

Step 3: Normalise every image before transfer

One function handles every photo whatever its source: detect HEIC by bytes, decode natively or with the fallback, apply orientation, downscale, re-encode.

import { isHeic, heicToJpeg } from "./heic.ts";

const MAX_EDGE = 2048;

export async function normaliseImage(file: File): Promise<File> {
  if (await isHeic(file)) return heicToJpeg(file);
  const bmp = await createImageBitmap(file, { imageOrientation: "from-image" });
  const s = Math.min(1, MAX_EDGE / Math.max(bmp.width, bmp.height));
  const c = new OffscreenCanvas(Math.round(bmp.width * s), Math.round(bmp.height * s));
  c.getContext("2d")!.drawImage(bmp, 0, 0, c.width, c.height);
  bmp.close();
  const blob = await c.convertToBlob({ type: "image/jpeg", quality: 0.85 });
  return new File([blob], file.name.replace(/\.\w+$/, ".jpg"), { type: "image/jpeg" });
}

Process selections one at a time. Decoding two 48-megapixel photos concurrently needs nearly 400 MB of pixel buffers, which is enough to crash the tab on a mid-range phone.

Step 4: Transfer in chunks that survive network changes

A mobile connection switching from Wi-Fi to cellular resets every open socket. With a single-request upload that is a restart from zero; with chunks it is one chunk.

export async function uploadInChunks(file: File, endpoint: string, chunk = 4 * 1024 * 1024): Promise<void> {
  let offset = Number((await fetch(endpoint, { method: "HEAD" })).headers.get("Upload-Offset") ?? 0);
  while (offset < file.size) {
    const body = file.slice(offset, Math.min(offset + chunk, file.size));
    try {
      const res = await fetch(endpoint, {
        method: "PATCH", body,
        headers: { "Upload-Offset": String(offset), "Content-Type": "application/offset+octet-stream" },
        signal: AbortSignal.timeout(60_000),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      offset += body.size;
    } catch {
      // Network changed or timed out: wait for connectivity, then re-read the server offset.
      if (!navigator.onLine) await new Promise((r) => window.addEventListener("online", r, { once: true }));
      offset = Number((await fetch(endpoint, { method: "HEAD" })).headers.get("Upload-Offset") ?? offset);
    }
  }
}

Mobile chunk sizes should be smaller than desktop ones — 2–4 MB rather than 8–16 — because the cost of a lost chunk on a weak connection is higher and throughput is lower. Adapting chunk size to measured throughput makes it automatic.

Step 5: Make stalls visible

Detect when progress stops and say so, rather than letting a frozen tab look like a slow upload.

export function watchStall(getOffset: () => number, onStall: (stalled: boolean) => void, ms = 15_000): () => void {
  let last = getOffset(), since = Date.now(), stalled = false;
  const id = setInterval(() => {
    const now = getOffset();
    if (now !== last) { last = now; since = Date.now(); if (stalled) onStall((stalled = false)); }
    else if (!stalled && Date.now() - since > ms) onStall((stalled = true));
  }, 1000);
  document.addEventListener("visibilitychange", () => { since = Date.now(); });   // a frozen tab is not a stall
  return () => clearInterval(id);
}

A stall message (“Upload paused — waiting for connection”) with a retry button converts a mystery into an action. The fuller pattern is in detecting stalled uploads with a progress watchdog.

Effect of normalising a batch of ten phone photos Ten 12 megapixel phone photos total about 34 megabytes as originals and take about 55 seconds on a 5 megabit uplink. Normalised to 2048 pixel JPEGs they total about 5.2 megabytes and upload in about 8 seconds. 10 phone photos on a 5 Mbit/s uplink originals (HEIC/JPEG) 34 MB · ≈ 55 s normalised 2048 JPEG 5.2 MB · ≈ 8 s Processing costs about 0.2–0.5 s per photo on the phone; the upload saves about 4.7 s per photo. It also removes GPS metadata and fixes orientation before anything leaves the device. On mobile, the fastest upload is the one you made smaller first.
Client-side normalisation is usually the single biggest improvement to mobile upload times.

Memory is the constraint nobody tests for

Desktop development machines have 16–64 GB of memory; the median Android phone in many markets has 4 GB shared between the OS, the browser and every other app. Browsers enforce per-tab limits well below that — a tab that allocates a few hundred megabytes on a mid-range phone is a candidate for being killed, and a killed tab reloads with no error message at all. Upload code that looks fine on a laptop can fail on a phone for no reason visible in any log.

Three allocations dominate. Decoded images cost width × height × 4 bytes: 48 MB for a 12-megapixel photo, 190 MB for 48 megapixels. Whole-file readsfile.arrayBuffer(), FileReader.readAsDataURL, base64 conversion — put the entire file in memory, and base64 adds a third on top. Recording buffers accumulate when MediaRecorder runs without a timeslice or when chunks are kept for a preview.

The mitigations follow directly. Decode one image at a time and call ImageBitmap.close() as soon as it has been drawn. Never read a whole file when a slice will do; pass File and Blob objects straight to fetch bodies, which stream from disk. Use a timeslice for recordings and drop chunks once they are acknowledged unless a preview needs them. And measure: Chrome’s remote debugging on a real Android device shows the tab’s memory in the Performance panel, which is the only honest way to find the peak.

When a design genuinely needs large allocations — stitching a panorama, trimming a long video in the browser — check navigator.deviceMemory (Chromium) and offer a server-side path for devices that report 4 GB or less. It is better to upload an original and let the server do the heavy work than to crash the tab of a user who has already spent minutes on the form.

Building a device test matrix that finds real bugs

Mobile upload bugs concentrate on a few device characteristics, so a small, deliberately chosen set of test devices finds most of them. Pick devices to cover the axes, not the brands.

Memory. Include one Android phone with 3–4 GB of RAM. It is the device that kills tabs during camera use and runs out of memory decoding large photos — the failures that never appear on flagship phones or in emulators.

Camera defaults. Include a current iPhone with the default “High Efficiency” camera format, so every test photo is HEIC with rotation metadata, and an Android phone whose camera writes JPEG with an EXIF orientation tag. Between them they exercise both orientation mechanisms and the HEIC path.

Browser engine. Every iOS browser uses WebKit, so iOS Chrome behaves like Safari for camera, file input and codec support. Android Chrome and Samsung Internet are both Chromium but differ in settings and update cadence. Firefox on Android is worth one pass for its different MediaRecorder output.

Network. Test on a real cellular connection while moving between Wi-Fi and mobile data. DevTools throttling reproduces low bandwidth but not the socket resets of a network change, which are what break single-request uploads.

Automate what can be automated — normalisation functions, chunk assembly, idempotency — with fixture files captured from these devices, and keep a short manual script for the rest. Record the device, OS and browser version with every production upload outcome, so when completion rates dip you can tell whether a new OS release changed camera or file-input behaviour before users tell you.

Accessibility of capture flows

Camera flows are often built as visual-only experiences, which excludes users of screen readers and switch devices. Make each state announced: a status region that says “Photo ready, 480 kilobytes” after normalising, “Uploading, 40 percent” during transfer and “Upload paused, waiting for connection” on a stall. Give the preview image meaningful alternative text, keep retake and remove actions as real buttons reachable by keyboard, and do not rely on the camera viewfinder for instructions — say what to photograph in text before the camera opens. The general patterns are in accessible upload interfaces.

Configuration reference

Setting Type Default here Effect
accept MIME list image/* Filters the picker; hints iOS to convert HEIC unless HEIC is listed.
capture environment / user on the camera input only Opens the camera app directly; ignored on desktop.
Max image edge pixels 2048 Downscale target before upload.
JPEG quality 0–1 0.85 Re-encode quality after normalising.
Concurrent decodes count 1 Peak memory on phones; never decode a batch in parallel.
Chunk size (mobile) bytes 2–4 MiB Bytes lost per network change.
Chunk timeout ms 60 000 Abort a chunk that stops moving.
Stall threshold ms 15 000 Show “paused” when progress stops this long.
Recording bitrate bps 2 500 000 MediaRecorder video bitrate for 720p speech.
Draft retention minutes 30 How long saved form state survives a reload.

Edge cases and gotchas

The page reloads after the camera

Mid-range Android devices routinely reclaim the browser’s memory while the camera app runs. The change event never fires because the page that would receive it no longer exists. Step 2’s draft saving recovers typed data; the photo itself is lost and the user has to take it again — tell them so explicitly on restore (“Your details were saved — please retake the photo”).

iOS converts behind your back

When accept does not list HEIC, iOS may transcode library photos to JPEG before handing them over, sometimes at a lower quality than you would choose. If you need originals, add image/heic,image/heif to accept and convert yourself.

Portrait photos arrive sideways

The pixels are stored landscape with an orientation tag. Any pipeline step that ignores the tag — an old canvas path, a server library without auto-orient — produces a sideways image. Apply orientation once, as early as possible, and strip the tag so nothing applies it twice.

Low Power Mode and background throttling

iOS Low Power Mode and Android battery savers throttle background tabs aggressively and can pause network activity. Uploads that are fine when the phone is on a charger stall on a train. Keep the upload tab in the foreground when possible, show progress prominently, and resume cleanly when it returns.

Metered connections

A user on cellular data may not want a 400 MB video uploaded immediately. The Network Information API (navigator.connection.saveData, effectiveType) is available in Chromium only; where present, offer “Upload on Wi-Fi” for large files and queue them in an outbox.

Where mobile uploads were lost before and after fixes Before fixes, of 100 started mobile photo uploads, 9 were lost to page reloads after the camera, 6 to HEIC decode errors, 11 to network changes and 5 abandoned during silent stalls, leaving 69 completed. After fixes, 94 completed. 100 started mobile uploads: what happened (illustrative) before completed 69 reload · HEIC · network · stall after completed 94 Each fix targets one bar: drafts for reloads, HEIC conversion, chunked resume, visible stalls.
No single change rescues mobile uploads; four small ones together remove most of the losses.

Verification

Build a device checklist and run it before every release that touches uploads:

iPhone (Safari)      take photo (camera) → upright preview, JPEG received, no GPS
iPhone (Safari)      choose HEIC from Files app → converted, preview shown
Android (Chrome)     take photo on a 3 GB device with 10 apps open → draft restored if reloaded
Android (Chrome)     start 50 MB video, toggle airplane mode for 20 s → resumes, one chunk re-sent
Both                 switch apps mid-upload for 60 s → "paused" shown, resumes on return
Desktop (Chrome)     both buttons open the file picker; HEIC converted with the fallback decoder

On the server, confirm what arrived:

exiftool -Orientation -GPSLatitude -ImageSize -FileType uploads/*.jpg | grep -E 'Orientation|GPS|Size|Type'

Frequently Asked Questions

Should I build a native app instead?

If uploads are the core of the product and users routinely send large videos, a native app’s background transfer APIs survive more than any browser can. For forms, reports, profile photos and moderate media, a well-built web flow with the steps above completes almost as reliably and needs no install.

How large should photos be after normalising?

2048 pixels on the long edge at JPEG quality 0.8–0.85 is a good default: sharp on large screens, around 400–700 KB. Keep originals only when the product genuinely needs them — printing, archival, forensic evidence — and upload those on Wi-Fi.

Why do some Android photos show the wrong date or no metadata at all?

Many Android gallery apps hand the browser a re-encoded copy of the photo, sometimes with metadata stripped and a fresh modification time. file.lastModified is therefore not a reliable capture time. If capture time matters, read the EXIF DateTimeOriginal from the original file before normalising, and fall back to the upload time when it is missing.

Is the capture attribute enough for document scanning?

For casual receipts, yes. For documents that must be legible — IDs, contracts — an in-page camera with edge detection, perspective correction and a “hold still” guide produces far better results, at the cost of a permission prompt and more code.