Queueing WebSocket Messages While Offline #
The user types a message in a lift, presses send, and socket.send() throws InvalidStateError because the socket closed thirty seconds ago. Or worse, it does not throw: the connection is in CLOSING, the call silently does nothing, the optimistic UI shows the message as sent, and it is gone forever. Either way, the user’s action was lost and the interface lied about it.
An outbox fixes this — but a naive one introduces bugs of its own: duplicates on retry, unbounded growth on a long outage, and messages that flush in the wrong order or against a stale session.
Root cause #
WebSocket.send() has no queueing semantics for a connection that is not open. In CONNECTING it throws InvalidStateError; in CLOSING or CLOSED it silently discards the data and, per the specification, adds its length to bufferedAmount without ever transmitting it. Neither behaviour tells the caller “this will be delivered later”, because nothing in the API promises that.
So any real-time app with user-initiated writes needs an outbox layer between the UI and the socket. Once you have one, three questions decide whether it helps or hurts:
Where does it live? In memory, it is lost on refresh — and a user who loses connectivity often reloads. In localStorage it survives, but it is shared across tabs, which means two tabs can flush the same message twice.
How large can it get? A user on a train for forty minutes can generate thousands of queued actions. Without a bound, the outbox grows until storage quota fails, which throws in the middle of a write path that was supposed to be the safe one.
What happens on flush? Sending everything the instant the socket opens is exactly wrong. The connection may open and immediately fail authentication, or the session may have been superseded, and a blind flush turns a recoverable state into duplicated or rejected writes.
Resolution #
A bounded, persisted outbox with idempotency keys, flushed only once the connection is genuinely ready to accept writes.
const MAX_QUEUED = 200;
const STORAGE_KEY = 'ws.outbox.v1';
const ACK_TIMEOUT_MS = 15_000;
interface Outgoing {
id: string; // idempotency key — stable across every retry
t: string;
d: unknown;
queuedAt: number;
}
export class Outbox {
private queue: Outgoing[] = [];
private inflight = new Map<string, ReturnType<typeof setTimeout>>();
constructor(private send: (msg: Outgoing) => boolean, private onDropped: (m: Outgoing) => void) {
this.queue = this.load();
}
private load(): Outgoing[] {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; }
}
private persist(): void {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(this.queue)); }
catch { this.queue.splice(0, Math.ceil(this.queue.length / 2)); } // quota: shed oldest half
}
enqueue(t: string, d: unknown): Outgoing {
const msg: Outgoing = { id: crypto.randomUUID(), t, d, queuedAt: Date.now() };
this.queue.push(msg);
// Bounded: drop the OLDEST and tell the UI, rather than failing the newest
// write the user just made or growing until storage throws.
while (this.queue.length > MAX_QUEUED) this.onDropped(this.queue.shift()!);
this.persist();
return msg;
}
/** Call only after the connection is authenticated and resumed — not on open. */
flush(): void {
for (const msg of [...this.queue]) {
if (this.inflight.has(msg.id)) continue;
if (!this.send(msg)) break; // socket closed mid-flush: stop, keep the rest
// The entry stays in the queue until the server acks it. A drop before the
// ack therefore replays it, which is why every message carries an id.
this.inflight.set(msg.id, setTimeout(() => this.inflight.delete(msg.id), ACK_TIMEOUT_MS));
}
}
ack(id: string): void {
const timer = this.inflight.get(id);
if (timer) clearTimeout(timer);
this.inflight.delete(id);
this.queue = this.queue.filter((m) => m.id !== id);
this.persist();
}
/** Connection lost: everything inflight is unconfirmed and must be resent. */
reset(): void {
this.inflight.forEach(clearTimeout);
this.inflight.clear();
}
get pending(): number { return this.queue.length; }
}
Three details carry the correctness. The idempotency id is generated once at enqueue and never regenerated, so a message sent twice is deduplicated server-side by idempotent WebSocket message processing. Entries are removed on ack, not on send, because a successful send() only means the bytes entered a buffer. And the flush stops at the first failure rather than continuing, preserving order — a partially flushed queue that skipped a message and sent the next one is worse than one that stopped.
The flush trigger matters as much as the flush itself:
// WRONG: the socket is open but may not yet be authorised or resumed.
socket.onopen = () => outbox.flush();
// RIGHT: flush when the application says the session is usable again.
onSessionReady(() => outbox.flush()); // after auth accepted AND resume drained
socket.onclose = () => outbox.reset(); // unconfirmed entries return to queued
Verification #
Test the outage, not just the send.
# Playwright: queue while offline, then confirm exactly-once delivery on return
npx playwright test tests/offline-outbox.spec.ts --trace on
await context.setOffline(true);
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByTestId('pending-count')).toHaveText('1');
await context.setOffline(false);
await expect(page.getByTestId('pending-count')).toHaveText('0', { timeout: 15_000 });
// The critical assertion: the message appears ONCE, not twice.
await expect(page.getByTestId('messages').getByText('hello')).toHaveCount(1);
Also test the ugly paths: reload while offline with a queued message (it must survive), queue past the cap (the oldest must be dropped with a visible notice, not silently), and disconnect mid-flush (the unacked entries must resend, and the server must deduplicate them). The duplicate assertion is the one that catches a missing idempotency key, and it is the bug users report as “my message posted twice”.
Operational checklist #
FAQ #
Should the UI show queued messages as sent? #
Show them, but visibly pending — greyed, with a small clock or “sending” marker — and never in the same style as confirmed messages. The whole failure this page prevents is an interface that claims delivery it cannot guarantee. Pair it with an explicit connection indicator, as covered in handling WebSocket disconnects gracefully, so the pending state has an explanation.
How do I stop two tabs flushing the same queue? #
Either isolate it — sessionStorage is per tab, which sidesteps the problem entirely — or elect a leader. The Web Locks API (navigator.locks.request) gives you a clean leader election in a few lines: only the lock holder flushes, and the others enqueue. Do not attempt to coordinate through localStorage events alone; the race window between read and write is real and it produces exactly the duplicates you were trying to avoid.
What if the server rejects a queued message on flush? #
Treat a nack as terminal for that entry: remove it from the queue and surface it to the user with the reason. Retrying a message the server has explicitly rejected will fail identically every time and, if the rejection is a rate limit, will make things worse. The one exception is a rejection that names a transient cause — 4401 token expired — where the correct behaviour is to refresh and retry once.
Does queueing break message ordering? #
Only if you let it. Flushing sequentially and stopping at the first failure preserves the order the user created; flushing in parallel does not. On the server side, the queued messages arrive after any live traffic that was sent while the socket was up, so they can appear “late” relative to other users — which is honest, since that is when they were delivered. If exact ordering across the gap matters, the server must order by an authoritative sequence, as described in handling out-of-order WebSocket messages.
Is a Service Worker better for this? #
For background sync — flushing after the tab is closed — yes, and the Background Sync API exists for exactly that. It is a significant amount of extra machinery for a queue that only needs to survive a lift ride, so start with the in-page outbox and add a Service Worker only if your product genuinely needs writes to complete after the user navigates away.
Related #
- State Sync & Optimistic Updates — the local-first model this outbox completes.
- Optimistic UI Rollback on WebSocket NACK — what to do when a flushed message is rejected.
- Handling Out-of-Order WebSocket Messages — inbound ordering, the mirror of this outbound problem.
- Resuming WebSocket Sessions After Reconnect — the server-side replay that pairs with a client outbox.
- Idempotent WebSocket Message Processing — why the idempotency key makes a retry safe.
Back to State Sync & Optimistic Updates