A field worker completes an inspection form in a basement with no signal, presses Submit, sees a network error, and has to keep the tab open and retry every few minutes — or loses the work when the browser reclaims the tab.

Submission state and optimistic updates assumes the request either succeeds or fails promptly. Offline-capable forms add a third outcome: accepted locally, delivery pending. This page builds that as an outbox — a durable queue of submissions with idempotency keys, a sender that drains it when connectivity returns, and UI that never claims “sent” before the server has said so.


Context and prerequisites

The outbox pattern has four parts:

  1. Capture — on submit, serialise the payload with a client-generated idempotency key and write it to IndexedDB before attempting the network.
  2. Send — try immediately; on success delete the entry, on network failure leave it queued.
  3. Drain — when connectivity returns (the online event, Background Sync, next app load, or a periodic retry), send queued entries in order.
  4. Report — show each submission’s real state: queued, sending, delivered, or needs attention (the server rejected it).

The idempotency key is non-negotiable. A request that timed out may have reached the server; replaying it without a key creates duplicates.

The life of a queued submission On submit the payload and a fresh idempotency key are written to the outbox in IndexedDB. An immediate send is attempted. If the device is offline or the request fails at the network level, the entry stays queued and the user sees saved on this device, will send when online. When connectivity returns, the drain sends entries in order. A 2xx response deletes the entry and shows delivered. A 4xx response moves the entry to needs attention so the user can fix it, and it is not retried automatically. Capture into outbox payload + idempotency key → IndexedDB Written before any network attempt, so a crash cannot lose it. try now Queued Network failure or offline. UI: saved on this device, will send when you are back online. online / sync / reload Draining Send in order, same key. UI: sending… response Delivered or needs attention 2xx: delete entry. 4xx: park it. Rejections are never retried automatically; the user fixes them.

The core pattern: a persisted outbox with idempotent replay

export interface OutboxEntry {
  id: string;                 // idempotency key, generated once at capture
  url: string;
  body: unknown;              // plain JSON; attachments as Blobs are fine in IndexedDB
  createdAt: number;
  attempts: number;
  state: "queued" | "sending" | "rejected";
  lastError?: string;
}

export interface OutboxStore {
  put(e: OutboxEntry): Promise<void>;
  delete(id: string): Promise<void>;
  listQueued(): Promise<OutboxEntry[]>;   // ordered by createdAt
}

export async function submitWithOutbox(store: OutboxStore, url: string, body: unknown, onChange: () => void) {
  const entry: OutboxEntry = {
    id: crypto.randomUUID(), url, body, createdAt: Date.now(), attempts: 0, state: "queued",
  };
  await store.put(entry);          // durable BEFORE the first attempt
  onChange();
  await drain(store, onChange);    // try immediately; stays queued on failure
  return entry.id;
}

let draining: Promise<void> | null = null;

export function drain(store: OutboxStore, onChange: () => void): Promise<void> {
  // One drain at a time: 'online', sync and manual retries can fire together.
  return (draining ??= (async () => {
    try {
      for (const e of await store.listQueued()) {
        await store.put({ ...e, state: "sending", attempts: e.attempts + 1 });
        onChange();
        let res: Response;
        try {
          res = await fetch(e.url, {
            method: "POST",
            headers: { "Content-Type": "application/json", "Idempotency-Key": e.id },
            body: JSON.stringify(e.body),
          });
        } catch {
          // Network-level failure: back to queued and stop — later entries
          // would fail the same way, and order must be preserved.
          await store.put({ ...e, state: "queued", attempts: e.attempts + 1 });
          onChange();
          return;
        }
        if (res.ok || res.status === 409) {
          // 409 with the same key usually means "already processed": treat as delivered
          // only if your API documents that; otherwise inspect the body.
          await store.delete(e.id);
        } else if (res.status >= 400 && res.status < 500) {
          await store.put({ ...e, state: "rejected", attempts: e.attempts + 1, lastError: await res.text() });
        } else {
          await store.put({ ...e, state: "queued", attempts: e.attempts + 1 });
          return;                  // 5xx: server trouble; retry later with backoff
        }
        onChange();
      }
    } finally {
      draining = null;
    }
  })());
}

// Triggers: any of these may fire; drain() coalesces them.
export function startDrainTriggers(store: OutboxStore, onChange: () => void) {
  const run = () => void drain(store, onChange);
  addEventListener("online", run);
  document.addEventListener("visibilitychange", () => { if (!document.hidden) run(); });
  run();                           // also on startup: entries may survive from last session
}

A service worker can call the same drain from a Background Sync sync event where the browser supports it, so queued entries are sent even after the tab is closed. Where it is not supported, the startup and online triggers cover the common case.


Step-by-step walkthrough

  1. Generate the idempotency key at capture. One key per user intent, reused on every retry. The server stores processed keys and returns the original result for repeats, as described in handling double submit and idempotency.
  2. Persist before sending. If the tab crashes between pressing Submit and the response, the entry is still in IndexedDB. The storage details are in storing large drafts in IndexedDB.
  3. Drain in order, one at a time. Order matters when submissions depend on each other (create, then update). Stop at the first network failure.
  4. Classify responses. 2xx is delivered. 4xx is a rejection the user must address — never retry it automatically. 5xx and network errors are transient and stay queued with backoff, as in retrying failed submissions with backoff.
  5. Trigger from several signals. online, visibilitychange, startup and Background Sync are each unreliable alone; together they drain promptly.
  6. Show the truth. “Saved on this device — will send when you’re back online” is not “Sent”. Offer a list of pending submissions with their state.
Response classes during a drain A network error returns the entry to queued, stops the drain and shows will send when online. A 2xx response deletes the entry, continues the drain and shows delivered. A 4xx rejection parks the entry as needs attention, continues with the next entry and shows a message with a link to fix it. A 5xx response returns the entry to queued, stops the drain for backoff and shows will retry shortly. Result Entry becomes Drain User sees network error queued stop will send when online 2xx deleted continue delivered 4xx rejected continue needs attention: fix and resend 5xx queued stop, back off will retry shortly

Failure modes and edge cases

1. navigator.onLine lies

navigator.onLine === true means “connected to some network”, not “the server is reachable” — captive portals and dead Wi-Fi report online. Always attempt the request and classify the result; use online only as a trigger to try.

2. Stale submissions

A form submitted offline on Monday and delivered on Thursday may conflict with changes made elsewhere in between. Include the base version the user edited in the payload so the server can answer with a conflict the user resolves, per handling 409 conflicts on form submit.

3. Authentication expires while queued

A token valid at capture may be expired at delivery, producing 401 for every entry. Treat 401 as “pause the queue and ask the user to sign in”, not as a rejection of each submission.

4. Sensitive data at rest

Queued payloads sit in IndexedDB unencrypted. Exclude secrets, and delete entries promptly once delivered. For shared devices, clear the outbox on sign-out after warning about undelivered items.

5. Duplicate triggers

online and Background Sync can fire at the same moment. The single draining promise ensures only one drain runs; without it, two drains can send the same entry concurrently — which the idempotency key makes harmless, but only if the server implements it.

Honest status wording For a queued entry show saved on this device, it will be sent when you are back online. For an entry being sent show sending. For a delivered entry show sent, with the time. For a rejected entry show that it could not be sent, the reason, and a link to fix and resend. Never show sent for a queued entry. Queued "Saved on this device. We'll send it when you're back online." Sending "Sending…" Delivered "Sent at 14:32." Needs attention "Couldn't be sent: postcode not recognised. Fix and resend."

Verification checklist


Frequently Asked Questions

Is Background Sync required?

No. It improves delivery when the tab is closed, and it is available in Chromium-based browsers. Without it, entries are sent the next time the app is opened or the online event fires, which is enough for most forms as long as the UI is honest about pending items.

Can I queue file uploads?

Yes — IndexedDB stores Blob and File objects directly. Large files make the queue slow to drain and can hit storage quotas, so show the size of pending uploads and consider resumable chunked uploads for anything over a few megabytes.

Should the form clear after an offline submit?

Clear the form so the user can start the next one, but keep the submission visible in a pending list with its state. Users who fill many forms offline need to see what is waiting and what has gone.


Related

← Submission State and Optimistic Updates