Queueing Offline Uploads with Background Sync

When the user submits a file, write the File (a Blob, which IndexedDB stores natively) and its metadata to an IndexedDB outbox, call registration.sync.register("upload-outbox"), and in the service worker’s sync event drain the outbox with ordinary fetch calls, deleting each record only after the server confirms it — so the browser retries automatically when connectivity returns, even if the tab has closed; where Background Sync is unavailable, drain the same outbox on the page’s online event.

Field workers photographing inspections, users on a train, anyone in a building with no signal: they attach a file, press send, and the app says “no connection”. An offline outbox makes that press succeed in the only sense the user cares about — the file will get there — and it turns connectivity into a detail the app handles rather than an error the user has to remember. This page is part of background and offline uploads in upload fundamentals and browser APIs. Storage details follow the same patterns as persisting upload state in IndexedDB.

When to use this approach

  • Users capture or attach files in places with intermittent connectivity, and the upload can wait minutes or hours.
  • Files are modest — photos, documents, short clips — so storing a few of them in IndexedDB is within quota.
  • The upload is fire-and-forget from the user’s point of view: they do not need to watch it complete.

Prerequisites

  1. A service worker registered with a scope covering the app.
  2. IndexedDB (every current browser) and navigator.storage.persist() to reduce the chance of eviction.
  3. Background Sync (SyncManager) — shipped in Chromium-based browsers; Safari and Firefox need the online-event fallback below.
  4. An upload endpoint that is idempotent per client-generated ID, because a sync handler may run twice for the same item — see retrying fetch uploads with idempotency keys.

The outbox pattern

Offline upload outbox with Background Sync The page writes the file and metadata into an IndexedDB outbox and registers a sync tag. When the browser detects connectivity it fires a sync event in the service worker, which reads each outbox item, uploads it with an idempotency key, and deletes the record after the server confirms. If the sync fails the browser retries later with backoff. Write locally now, send when the network allows page user taps send IndexedDB outbox Blob + metadata sync event when online server idempotent PUT delete record only after 2xx throw from the handler → browser retries later The tab can close after "send"; the service worker is woken for the sync without any page open.
The outbox is the source of truth; the sync event is only a trigger to drain it.

Implementation

A tiny IndexedDB wrapper shared by the page and the service worker:

// outbox.ts — imported by both the page and the service worker.
export interface OutboxItem {
  id: string;            // client-generated, doubles as the idempotency key
  name: string;
  type: string;
  size: number;
  blob: Blob;
  endpoint: string;      // where to send it
  createdAt: number;
  attempts: number;
}

const DB = "uploads", STORE = "outbox";

function open(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB, 1);
    req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: "id" });
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function tx<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
  const db = await open();
  return new Promise((resolve, reject) => {
    const t = db.transaction(STORE, mode);
    const r = fn(t.objectStore(STORE));
    t.oncomplete = () => { db.close(); resolve(r.result); };
    t.onerror = () => { db.close(); reject(t.error); };
  });
}

export const putItem = (i: OutboxItem) => tx("readwrite", (s) => s.put(i));
export const deleteItem = (id: string) => tx("readwrite", (s) => s.delete(id));
export const allItems = () => tx<OutboxItem[]>("readonly", (s) => s.getAll() as IDBRequest<OutboxItem[]>);

The page queues the file and asks for a sync:

import { putItem, allItems } from "./outbox.ts";

type SyncReg = ServiceWorkerRegistration & { sync?: { register(tag: string): Promise<void> } };

export async function queueUpload(file: File, endpoint: string): Promise<string> {
  const id = crypto.randomUUID();
  await navigator.storage?.persist?.();              // ask the browser not to evict the outbox
  await putItem({ id, name: file.name, type: file.type, size: file.size, blob: file,
    endpoint, createdAt: Date.now(), attempts: 0 });

  const reg = (await navigator.serviceWorker.ready) as SyncReg;
  if (reg.sync) {
    await reg.sync.register("upload-outbox");        // fires now if online, later if not
  } else {
    if (navigator.onLine) void drainFromPage();
    window.addEventListener("online", () => void drainFromPage(), { once: true });
  }
  return id;
}

/** Fallback for browsers without Background Sync: drain while the page is open. */
async function drainFromPage(): Promise<void> {
  navigator.serviceWorker.controller?.postMessage({ type: "drain-outbox" });
}

export async function pendingCount(): Promise<number> {
  return (await allItems()).length;
}

And the service worker drains it:

/// <reference lib="webworker" />
import { allItems, deleteItem, putItem } from "./outbox.ts";
declare const self: ServiceWorkerGlobalScope;

const MAX_ATTEMPTS = 8;

async function drain(): Promise<void> {
  const items = (await allItems()).sort((a, b) => a.createdAt - b.createdAt);
  let transientFailure = false;
  for (const item of items) {
    try {
      const res = await fetch(item.endpoint, {
        method: "PUT",
        body: item.blob,
        headers: { "Content-Type": item.type || "application/octet-stream", "Idempotency-Key": item.id },
      });
      if (res.ok || res.status === 409) { await deleteItem(item.id); continue; }   // 409: already have it
      if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {
        await deleteItem(item.id);                      // permanent: tell the user, do not loop
        await notifyClients({ type: "upload-rejected", id: item.id, status: res.status });
        continue;
      }
      transientFailure = true;
    } catch {
      transientFailure = true;                          // offline again, DNS, reset
    }
    item.attempts += 1;
    if (item.attempts >= MAX_ATTEMPTS) {
      await deleteItem(item.id);
      await notifyClients({ type: "upload-abandoned", id: item.id });
    } else {
      await putItem(item);
    }
  }
  if (transientFailure) throw new Error("outbox not fully drained");   // makes the browser retry
}

async function notifyClients(msg: object): Promise<void> {
  for (const c of await self.clients.matchAll({ includeUncontrolled: true })) c.postMessage(msg);
}

self.addEventListener("sync", (e: Event) => {
  const event = e as ExtendableEvent & { tag: string };
  if (event.tag === "upload-outbox") event.waitUntil(drain());
});

self.addEventListener("message", (e: ExtendableMessageEvent) => {
  if (e.data?.type === "drain-outbox") e.waitUntil(drain().catch(() => undefined));
});

Line-by-line on the decisions that matter

  • Storing the File directly. IndexedDB stores Blob and File objects via structured clone without base64 conversion. Converting to a data URL first triples memory use and inflates stored size by a third — the costs described in base64 vs binary encoding.
  • navigator.storage.persist(). Without it, IndexedDB data is “best effort” and can be evicted under storage pressure — the worst possible outcome for a queued upload. Browsers grant persistence more readily to installed apps and sites with engagement.
  • Throwing from drain() on transient failure. The sync event’s promise rejecting is the signal to retry; the browser schedules the next attempt with its own backoff. Resolving on failure would tell it the job is done.
  • Deleting on permanent 4xx. A 403 from an expired credential or a 413 for an oversized file will fail forever. Remove the item and tell the user, or the outbox blocks every later upload behind it.
  • Idempotency-Key equal to the item ID. The browser may run a sync handler, lose the response to a network blip after the server stored the file, and run it again. The server recognises the key and returns 409 or the original 2xx.
  • MAX_ATTEMPTS. Chromium itself gives up on a sync tag after a few attempts; the counter makes sure a doomed item is eventually reported rather than retried forever through the page fallback.

When the browser actually retries

Sync attempts over an offline period A file is queued while offline at time zero. No sync fires while offline. When connectivity returns at 40 minutes the browser fires the sync event; the first attempt fails because the connection is captive, and a retry a few minutes later succeeds. Offline for 40 minutes, then a flaky reconnect offline — no sync fires, item waits online queued sync #1 fails sync #2 succeeds Retry timing is the browser's choice — typically minutes apart, with a small cap on attempts per tag.
You decide what "done" means; the browser decides when to try again.

Configuration gotchas

DOMException: The object store currently does not support blob values. Very old Safari versions could not store Blobs in IndexedDB. Current versions can; if you must support older ones, store an ArrayBuffer from file.arrayBuffer() instead — at the cost of reading the whole file into memory once.

QuotaExceededError on put. The origin’s quota is shared across all storage. Check navigator.storage.estimate() before queueing large files and refuse with a clear message (“Not enough space to save this for later — connect to Wi-Fi to upload now”).

Sync never fires in DevTools testing. Toggling “Offline” in the Network panel does not always trigger sync on reconnect. Use Application → Service Workers → “Sync” with the tag name to fire it manually.

Credentials expired while queued. A presigned URL created at queue time may be stale hours later. Store the target asset instead, and have the service worker request a fresh upload URL from your API immediately before sending.

What the user should see

Outbox states shown in the UI Each queued item shows one of four states: saved offline with a cloud icon, sending, sent, or needs attention for permanent rejections. A banner shows the count of items waiting for a connection. Four states, all explicit saved offline "Will upload when you're back online" sending spinner, no percent (SW has no progress) sent tick, then fades record deleted needs attention "Too large" / "Not allowed" + retry Show the outbox count in a banner while items wait, so users know not to re-attach the same file. Never show "failed" for offline — it is the expected state, not an error.
Offline is a normal state with a friendly label; only permanent rejections deserve error styling.

Verification

In Chrome DevTools:

  1. Application → Service Workers: confirm the worker is active. Network → set “Offline”.
  2. Attach a file and submit; Application → IndexedDB → uploads/outbox shows one record with a Blob.
  3. Application → Background Services → Background Sync → start recording; set Network back to “No throttling”.
  4. The panel logs a sync event for upload-outbox; the IndexedDB record disappears and your server log shows one PUT with the item’s Idempotency-Key.
// Console check after draining: nothing left behind.
const req = indexedDB.open("uploads");
req.onsuccess = () => {
  const r = req.result.transaction("outbox").objectStore("outbox").count();
  r.onsuccess = () => console.log("pending uploads:", r.result);   // 0
};

Frequently Asked Questions

What is the difference between Background Sync and Background Fetch?

Background Sync wakes your service worker when connectivity returns so your own code can send data; the work must finish within the worker’s short lifetime. Background Fetch hands a long transfer to the browser, which manages it and shows progress. Use Sync for queued small uploads, Fetch for single large ones.

Can Periodic Background Sync upload on a schedule instead?

Periodic Sync is for refreshing content at intervals chosen by the browser, is limited to installed apps with engagement, and is not a reliable trigger for user uploads. One-off Sync fires promptly on reconnect, which is what an outbox needs.

How much can I queue?

As much as the origin’s storage quota allows — typically a large fraction of free disk in Chromium and a smaller, prompt-gated amount in Safari. Keep the outbox for files the user is waiting to send, not a general-purpose cache, and check navigator.storage.estimate() before queueing big ones.