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;
}
Ordered drain with a rejection and a transient failure Six numbered intent blocks in a row. The first three are marked sent. The fourth is marked rejected with a note that its optimistic effect was undone and the user was told. The fifth is marked failed transiently, which pauses the drain, and the sixth remains queued in its original position. queue, in the order the user acted 1 · sent job.complete 2 · sent job.complete 3 · sent cart.remove 4 · rejected 409 conflict 5 · failed network — kept 6 · queued not attempted optimistic effect undone + user told which action failed removed from the queue: retrying a 409 never helps drain pauses here — order is preserved intent 6 may depend on 5; skipping ahead corrupts state

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.

Generic versus specific reconciliation feedback Two message mock-ups. The left, labelled generic, reads some changes could not be synced, with notes that the user cannot tell which action failed and will likely redo everything. The right, labelled specific, names the job, gives the reason it was rejected, and offers a review link. generic specific "Some changes could not be synced" no record named, no reason, no action user redoes all six actions duplicates appear despite idempotency keys "Boiler service, Flat 3B stayed open" it was reassigned while you were offline [ Review job ] user checks one job the other five stay synced and untouched reconciliation quality decides whether idempotency keys ever get exercised One key, created once, reused by every replay A key is generated when the user taps. The same key travels with a failed first attempt, a retried second attempt and a replay after a reload. The server records it once and returns the same result for the later attempts. user taps key generated here attempt 1 — offline attempt 2 — timeout attempt 3 — accepted same key throughout server keeps one record for the key a fourth replay returns the same result, not a duplicate

Verification #

  1. Act offline, close the tab, reopen. All six intents must still be queued and badged, and the drain must start when connectivity returns.
  2. Force a duplicate replay. Drain twice with the same keys and assert the backend holds one record per intent.
  3. Reject the fourth intent. Confirm the drain continues past a decided rejection, the optimistic effect is undone, and the message names the record.
  4. Fail the fifth transiently. Confirm the drain pauses rather than continuing to the sixth, and resumes in order later.
  5. 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.