This page builds the action queue that makes the offline states in Connectivity Detection & Offline UX Patterns useful rather than merely informative.
The exact problem #
A field engineer marks six jobs complete in a basement with no signal. The app disables its buttons while offline, so nothing happens. They drive back, reopen the app, and discover none of it was recorded — the app told them it was offline but did nothing to preserve their work.
The alternative failure is just as bad: the app accepts all six taps optimistically, keeps nothing durable, and the tab is killed by the OS before connectivity returns. The user saw six confirmations for work that was never saved anywhere.
Doing this properly needs three things: a durable queue, a visible pending state, and a drain that reconciles honestly when the server disagrees.
Zero-to-working queue #
// intents.ts
export type Intent =
| { kind: 'job.complete'; jobId: string; completedAt: number; note?: string }
| { kind: 'cart.remove'; itemId: string }
| { kind: 'cart.setQuantity'; itemId: string; quantity: number };
export interface QueuedIntent {
id: string; // also the idempotency key
seq: number; // preserves the user's order
intent: Intent;
createdAt: number;
attempts: number;
lastError?: string;
}
const DB = 'offline_queue_v1';
const STORE = 'intents';
export async function enqueue(intent: Intent): Promise<QueuedIntent> {
const db = await openDb();
const record: QueuedIntent = {
// Generated NOW, so every replay of this action carries the same key.
id: crypto.randomUUID(),
seq: Date.now() * 1000 + Math.floor(Math.random() * 1000),
intent,
createdAt: Date.now(),
attempts: 0,
};
await put(db, STORE, record);
return record;
}
export async function pending(): Promise<QueuedIntent[]> {
const db = await openDb();
const all = await getAll<QueuedIntent>(db, STORE);
return all.sort((a, b) => a.seq - b.seq); // user order, always
}
// drain.ts
export async function drainQueue(monitor: Monitor): Promise<DrainReport> {
const items = await pending();
const report: DrainReport = { sent: 0, failed: 0, rejected: [] };
for (const item of items) {
if (monitor.state !== 'online') break; // lost it again mid-drain
try {
const res = await fetch(endpointFor(item.intent), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': item.id },
body: JSON.stringify(item.intent),
});
if (res.status === 409 || res.status === 422) {
// The server refuses this intent — it will never succeed on retry.
report.rejected.push({ item, reason: await res.text() });
await remove(item.id);
await undoOptimistic(item.intent);
continue; // a rejection is decided, so move on
}
if (!res.ok) throw new Error(`replay failed: ${res.status}`);
await remove(item.id);
report.sent += 1;
} catch (err) {
// Transient: keep it, stop the drain, preserve ordering.
await bumpAttempts(item.id, String(err));
report.failed += 1;
monitor.reportFailure();
break;
}
}
return report;
}
Making the queue visible #
// PendingBadge.tsx
export function JobRow({ job, pendingIntent }: { job: Job; pendingIntent?: QueuedIntent }) {
return (
<li>
<span>{job.title}</span>
{pendingIntent ? (
<span className="badge badge--pending">
Waiting to sync{pendingIntent.attempts > 0 ? ` · ${pendingIntent.attempts} attempts` : ''}
</span>
) : (
<span className="badge badge--synced">Saved</span>
)}
</li>
);
}
The pending badge is not decoration. It is the difference between “the app accepted my work” and “the app pretended to accept my work”: a user who can see that six items are waiting will not redo them, and will not close the tab believing everything is saved. Pair it with the banner count from the parent guide so the total is visible even when the affected rows are scrolled out of view.
| Situation | Queue behaviour | What the user sees |
|---|---|---|
| Action taken while offline | Enqueued, optimistic effect applied | Item badged “waiting to sync” |
| Reconnect | Drain in order with jitter | Banner: “Syncing 6 changes…” |
| One intent rejected (409/422) | Removed, effect undone | “Job 4 could not be completed — it was reassigned” |
| Transient failure mid-drain | Drain pauses, order preserved | Banner: “4 of 6 synced, retrying” |
| Tab closed before draining | Queue survives in IndexedDB | Next open resumes the drain |
| Second tab open | One drainer via Web Locks | No duplicate requests |
Reconciliation, not silence #
A queued intent that the server refuses is the hardest case, because the user already saw a confirmation. The rule is to be specific and reversible:
// reconcile.ts
export async function undoOptimistic(intent: Intent): Promise<void> {
switch (intent.kind) {
case 'job.complete':
store.setJobStatus(intent.jobId, 'open');
notify({
severity: 'warning',
title: 'One update could not be saved',
body: `“${store.jobTitle(intent.jobId)}” was reassigned while you were offline, so it stayed open.`,
action: { label: 'Review job', href: `/jobs/${intent.jobId}` },
});
break;
case 'cart.remove':
store.restoreItem(intent.itemId);
notify({ severity: 'info', title: 'An item came back', body: 'It could not be removed — it is already in an order.' });
break;
case 'cart.setQuantity':
store.refreshItem(intent.itemId);
break;
}
}
Naming the specific record, explaining why, and offering a route to it turns a data-integrity problem into an ordinary piece of feedback. A generic “some changes could not be synced” toast leaves the user unable to work out what to redo — and they will usually redo everything, which is how duplicates get created despite idempotency keys.
Verification #
- Act offline, close the tab, reopen. All six intents must still be queued and badged, and the drain must start when connectivity returns.
- Force a duplicate replay. Drain twice with the same keys and assert the backend holds one record per intent.
- Reject the fourth intent. Confirm the drain continues past a decided rejection, the optimistic effect is undone, and the message names the record.
- Fail the fifth transiently. Confirm the drain pauses rather than continuing to the sixth, and resumes in order later.
- Open two tabs. With leader election from Electing a Leader Tab with the Web Locks API, exactly one tab should issue replay requests.
One operational note: log the queue depth alongside your crash reports. A queue that regularly exceeds a handful of entries is telling you that either connectivity is worse than you assumed for a segment of users, or an intent is failing repeatedly without being surfaced — both of which are easier to notice in a metric than in individual support tickets.
Frequently asked questions #
Should I queue requests or intents?
Intents. A serialised request pins you to today’s endpoint shape, headers and token, all of which can change before the queue drains.
What happens if the queue drains while a second tab is open?
Both tabs replay unless you coordinate. Elect one drainer with Web Locks and keep intents idempotent.
How do I handle an intent the server rejects on replay?
Undo its optimistic effect and surface a specific message naming the action. Silently dropping it leaves the user believing something happened that did not.
Related #
- Connectivity Detection & Offline UX Patterns — the state that drives this queue
- Background Sync & Request Replay Queues — draining from the service worker after the tab has closed
- Queuing Failed POST Requests with the Background Sync API — the browser-managed replay path