+1 (415) 843-4662

Photo and video capture in Ionic apps: compression, resumable uploads and an upload queue that survives the app being killed

Almost every field-service, insurance, inspection and marketplace app we are brought into has the same feature: the user takes a photo (or a short video), adds a note, and it has to end up in your storage bucket. It sounds like a weekend feature. It is not. The parts that break in production are never the Camera.getPhoto() call — they are the 12 MP originals blowing up your bandwidth bill, the EXIF GPS coordinates you did not mean to store, the upload that dies at 80% on a warehouse Wi-Fi, and the user who swipes the app away mid-upload and loses a morning of inspections.

This tutorial builds the whole pipeline for an Ionic app on Capacitor: capture, on-device processing, durable local storage, and a resumable upload queue with a persistent job table. Examples are Ionic Angular flavoured, but the services are plain TypeScript and drop straight into React or Vue.

The shape of the pipeline

capture -> process (resize, re-encode, strip EXIF) -> persist to Filesystem
        -> enqueue job in SQLite -> uploader (presigned, chunked, resumable)
        -> confirm with API -> delete local file

The important design decision: the UI never awaits the upload. Capture writes a file and a queue row, then returns. Everything after that is a background worker that can retry forever. That single split is what makes the feature survive tunnels, lifts and task-switchers.

1. Capture

npm i @capacitor/camera @capacitor/filesystem
npx cap sync

Ask for a file URI, not a base64 string. Base64 photos are a classic Ionic memory bug: a 12 MP JPEG becomes a ~8 MB string, then a data URL in the DOM, and low-end Android devices start killing the WebView.

import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';

async function capture(): Promise<string> {
  const photo = await Camera.getPhoto({
    resultType: CameraResultType.Uri,   // NOT Base64 / DataUrl
    source: CameraSource.Prompt,
    quality: 90,                        // we re-encode ourselves below
    correctOrientation: true,
    saveToGallery: false,
    presentationStyle: 'fullscreen',
  });
  return photo.path ?? photo.webPath!;  // native path on device, blob URL on web
}

Permission strings matter for review. In Info.plist:

<key>NSCameraUsageDescription</key>
<string>Used to photograph the equipment you are inspecting.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Used to save inspection photos you choose to keep.</string>

On Android 13+ you no longer need READ_EXTERNAL_STORAGE for picking media — the photo picker handles it. Delete the legacy storage permissions from your manifest; leaving them in triggers Play Console data-access questionnaires you do not want to answer.

2. Process on device: resize, re-encode, strip EXIF

A modern phone camera produces 4–8 MB JPEGs. Your backend almost certainly renders them at 1600px. Shipping the original is pure waste — and it carries GPS EXIF tags that put you in scope for location-data disclosure on both stores.

Do it in a canvas, off the main thread where you can:

export async function processImage(
  blob: Blob,
  maxEdge = 1600,
  quality = 0.8,
): Promise<Blob> {
  const bitmap = await createImageBitmap(blob);   // honours orientation
  const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
  const w = Math.round(bitmap.width * scale);
  const h = Math.round(bitmap.height * scale);

  const canvas = new OffscreenCanvas(w, h);
  const ctx = canvas.getContext('2d')!;
  ctx.drawImage(bitmap, 0, 0, w, h);
  bitmap.close();

  // Canvas output contains no EXIF at all: metadata is stripped for free.
  return canvas.convertToBlob({ type: 'image/jpeg', quality });
}

Two notes from real projects:

  • Keep the data you actually need. Stripping EXIF also throws away the capture timestamp and orientation. Read what you need before re-encoding and store it as normal fields on the job row, where it is auditable.
  • WebP/AVIF are tempting. WebP at quality 0.8 is roughly 25–30% smaller than JPEG, and both platforms decode it. Only switch if every downstream consumer (PDF generator, third-party CRM, the ops team's Windows viewer) handles it. Otherwise convert server-side.

Video is a different animal: do not try to transcode in the WebView. Set the capture quality at source (AVCaptureSessionPreset / Android QUALITY_HD via a plugin), cap duration in the UI, and let the server transcode.

3. Persist the file before you promise anything

Write the processed blob into the app's private data directory. Never keep it only in memory, and never keep it only in Cache — iOS will evict Directory.Cache under storage pressure and your queue will point at nothing.

import { Filesystem, Directory } from '@capacitor/filesystem';

async function persist(blob: Blob, id: string): Promise<string> {
  const base64 = await blobToBase64(blob);
  const res = await Filesystem.writeFile({
    path: `uploads/${id}.jpg`,
    data: base64,
    directory: Directory.Data,
    recursive: true,
  });
  return res.uri;
}

If you also store the photo in SQLite or sync it (see our offline-first post), store the path, not the bytes. Blobs in SQLite make your database file enormous and your backups slow.

4. The queue table

One row per pending upload, in the same SQLite database as the rest of your offline state so a single transaction can create the inspection and its photos.

CREATE TABLE IF NOT EXISTS upload_jobs (
  id            TEXT PRIMARY KEY,
  entity_id     TEXT NOT NULL,
  file_uri      TEXT NOT NULL,
  byte_size     INTEGER NOT NULL,
  content_type  TEXT NOT NULL,
  session_url   TEXT,                       -- resumable session, once created
  bytes_sent    INTEGER NOT NULL DEFAULT 0,
  state         TEXT NOT NULL DEFAULT 'pending',  -- pending|uploading|done|failed
  attempts      INTEGER NOT NULL DEFAULT 0,
  next_attempt  INTEGER NOT NULL DEFAULT 0,  -- epoch ms, for backoff
  created_at    INTEGER NOT NULL
);

session_url and bytes_sent are what make the upload resumable across app restarts rather than merely retryable.

5. Resumable uploads

Small files (< ~5 MB): a single PUT to a presigned URL is fine. Anything bigger, or anything captured in the field, should use a resumable protocol. All the major options work the same way — create a session, PUT byte ranges, ask the server where it got to after a failure:

BackendCreate sessionResume
S3 multipartCreateMultipartUpload (presign each part)re-PUT missing parts, then CompleteMultipartUpload
GCS resumablePOST ?uploadType=resumable → session URIPUT with Content-Range: bytes */TOTAL returns last received byte
tus (self-host)POST /files → LocationHEAD returns Upload-Offset

A GCS-style uploader, chunked so a failure costs one chunk and not the file:

const CHUNK = 8 * 1024 * 1024; // 8 MB

async function uploadChunked(job: UploadJob, signal: AbortSignal) {
  let offset = job.bytes_sent;

  // Recover the true offset from the server: our local counter may be stale
  // if the process died mid-PUT.
  const probe = await fetch(job.session_url!, {
    method: 'PUT',
    headers: { 'Content-Range': `bytes */${job.byte_size}` },
  });
  if (probe.status === 308) {
    const range = probe.headers.get('Range');          // e.g. "bytes=0-8388607"
    offset = range ? Number(range.split('-')[1]) + 1 : 0;
  } else if (probe.ok) {
    return finish(job);                                 // already complete
  }

  while (offset < job.byte_size) {
    const end = Math.min(offset + CHUNK, job.byte_size) - 1;
    const slice = await readSlice(job.file_uri, offset, end);

    const res = await fetch(job.session_url!, {
      method: 'PUT',
      headers: {
        'Content-Range': `bytes ${offset}-${end}/${job.byte_size}`,
        'Content-Type': job.content_type,
      },
      body: slice,
      signal,
    });

    if (res.status === 308) {
      offset = end + 1;
      await db.run('UPDATE upload_jobs SET bytes_sent=? WHERE id=?', [offset, job.id]);
      continue;
    }
    if (res.ok) return finish(job);                    // 200/201 = done
    throw new Error(`upload failed: ${res.status}`);
  }
}

readSlice reads a byte range out of the file rather than loading the whole thing: Filesystem.readFile accepts no range, so either read once into a Blob and use blob.slice(), or use fetch(convertFileSrc(uri)) and slice the response — the latter keeps peak memory to one chunk, which matters on 2 GB Android devices.

6. The worker: backoff, concurrency, and when to run

import { Network } from '@capacitor/network';
import { App } from '@capacitor/app';

class UploadWorker {
  private running = false;

  start() {
    void this.tick();
    Network.addListener('networkStatusChange', s => s.connected && this.tick());
    App.addListener('appStateChange', s => s.isActive && this.tick());
  }

  private async tick() {
    if (this.running) return;
    this.running = true;
    try {
      const { connected, connectionType } = await Network.getStatus();
      if (!connected) return;

      const jobs = await db.query(
        `SELECT * FROM upload_jobs
          WHERE state IN ('pending','uploading') AND next_attempt <= ?
          ORDER BY created_at LIMIT 2`,
        [Date.now()],
      );

      for (const job of jobs) {
        if (connectionType === 'cellular' && job.byte_size > BIG && !job.user_forced) continue;
        try {
          await this.run(job);
        } catch {
          const attempts = job.attempts + 1;
          const delay = Math.min(2 ** attempts * 1000, 5 * 60_000)
                      + Math.random() * 1000;          // jitter
          await db.run(
            `UPDATE upload_jobs SET attempts=?, next_attempt=?,
             state=? WHERE id=?`,
            [attempts, Date.now() + delay, attempts > 10 ? 'failed' : 'pending', job.id],
          );
        }
      }
    } finally {
      this.running = false;
    }
  }
}

Concurrency of two is the sweet spot we keep landing on: more parallel uploads on a weak connection just make every one of them time out.

To keep going for a minute or two after the user backgrounds the app, wrap the tick in a background task (@capacitor/background-runner, or beginBackgroundTask / WorkManager in a small custom plugin — see our post on writing your own Capacitor plugin). Be honest about the limits: iOS gives you seconds to a few minutes, not hours. True background transfer on iOS requires URLSession background sessions, which means native code; it is worth it only when users routinely upload large video while the app is closed.

7. Confirming and cleaning up

The upload is not done when storage returns 200. It is done when your API has recorded it:

async function finish(job: UploadJob) {
  await api.post(`/inspections/${job.entity_id}/photos`, {
    uploadId: job.id,          // idempotency key: safe to retry
    objectKey: job.object_key,
    capturedAt: job.captured_at,
  });
  await db.run(`UPDATE upload_jobs SET state='done' WHERE id=?`, [job.id]);
  await Filesystem.deleteFile({ path: `uploads/${job.id}.jpg`, directory: Directory.Data });
}

Send the same uploadId every retry and make the endpoint idempotent — otherwise flaky networks give you duplicate photos, which is the single most common bug report on features like this. Add a startup sweep that deletes orphan files with no pending job row; without it, Directory.Data grows forever and users start blaming your app for their storage warnings.

8. What the user sees

  • Show the local file immediately via Capacitor.convertFileSrc(uri). The photo appears in the list before a single byte has left the device.
  • Put a small per-item state chip on the thumbnail: queued / uploading (with bytes_sent / byte_size) / failed with a retry button.
  • Never block navigation on an upload, and never show a blocking spinner.
  • Surface a single "3 photos waiting to upload" banner rather than per-photo errors.

Test it like a field device

  1. Airplane mode mid-upload; restore network — it resumes from the offset.
  2. Force-quit the app at ~50%; relaunch — it resumes, not restarts.
  3. Throttle to 3G in the simulator/emulator and upload a 40 MB video.
  4. Fill the device storage and confirm you fail gracefully, not silently.
  5. Check the uploaded file in your bucket has no GPS EXIF.

Add steps 1–3 to your Maestro suite so they run on every release, not just the release where someone remembered.

Need this built properly?

Media capture is one of those features where the demo takes a day and the production version takes three weeks. HybridMob's senior Ionic consultants have shipped this pipeline in inspection, insurance and logistics apps — including the native background-transfer plugin work when the WebView is not enough. Get in touch and tell us what your users are capturing.