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:
- Capture — on submit, serialise the payload with a client-generated idempotency key and write it to IndexedDB before attempting the network.
- Send — try immediately; on success delete the entry, on network failure leave it queued.
- Drain — when connectivity returns (the
onlineevent, Background Sync, next app load, or a periodic retry), send queued entries in order. - 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 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
- 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.
- 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.
- Drain in order, one at a time. Order matters when submissions depend on each other (create, then update). Stop at the first network failure.
- 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.
- Trigger from several signals.
online,visibilitychange, startup and Background Sync are each unreliable alone; together they drain promptly. - 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.
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.
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.