Syncing Redux state with WebSocket streams #
You wired a WebSocket into your Redux store, and most of the time it works. Then a tab gets backgrounded, a phone hops from Wi-Fi to LTE, or a load balancer culls an idle connection — and suddenly Redux DevTools shows duplicate actions firing within milliseconds, list items appearing twice, and a heap that grows on every reconnect. The state tree that should converge instead flickers, because stale frames overwrite fresh ones. If you landed here after watching a normalized slice get clobbered right after a reconnect, this page is about why that happens at the protocol and runtime level, and the exact middleware to stop it.
The failure is deterministic once you see it: WebSocket frames are not idempotent, your reducers assume they are, and the browser hands you a burst of buffered frames the instant a suspended tab wakes. This is the ordering side of real-time UI state. The acknowledgement side — undoing a local change the server rejected — is covered in optimistic UI rollback on WebSocket NACK, and the two patterns compose cleanly.
Root cause #
Three runtime facts combine to corrupt the store:
- The browser buffers frames during tab suspension. When a tab is backgrounded, timers throttle but the TCP socket stays open and the WebSocket receive buffer keeps accumulating frames. On focus restoration the event loop drains that buffer, so your
onmessagehandler fires many times in a single microtask burst — far faster than any debounce assumes. - WebSocket delivery is ordered per-connection, but reconnects create a new connection. TCP guarantees in-order bytes within one socket. The moment a dropped connection is replaced, the new socket has no memory of what the old one delivered. The server may replay frames you already applied, or skip frames you never received — there is no transport-level sequence shared across the two sockets.
- Redux reducers are pure and order-dependent. A reducer that applies a delta patch (
items[id] = patch) assumes it sees every patch exactly once and in order. Feed it a duplicate and it re-applies; feed it an out-of-order frame and it merges against a state that does not exist yet. Nothing in Redux validates this — it dispatches whatever you hand it.
The original buggy handler makes all three problems unavoidable because it dispatches blindly:
ws.onmessage = (event) => {
const payload = JSON.parse(event.data);
// No seq_id check, no dedup — every buffered/replayed frame mutates state
store.dispatch({ type: payload.type, payload: payload.data });
};
The fix is to make the dispatch pipeline reject anything it has already seen and hold anything that arrives too early. That requires a monotonic sequence number stamped by the server and a small reordering buffer in front of the reducers.
Resolution #
Insert a sequence-aware Redux middleware between the raw socket and your reducers. It dispatches in-order frames immediately, discards anything already applied, and buffers out-of-order frames in a bounded map until the gap is filled. On reconnect it resets and requests a fresh snapshot, because — as established above — sequence numbers do not survive across sockets.
import { Middleware, Dispatch, AnyAction } from '@reduxjs/toolkit';
const MAX_QUEUE_DEPTH = 500; // hard cap so a permanent gap can't leak memory
const RESYNC_THRESHOLD = 200; // sustained backlog above this means "request snapshot"
interface WSFrame {
seq_id: number; // monotonic, assigned by the server per logical stream
inner: { type: string; data: unknown };
}
let lastProcessedSeq = 0;
const pending = new Map<number, WSFrame>(); // out-of-order frames keyed by seq_id
export const wsSequenceMiddleware: Middleware = () => (next) => (action: AnyAction) => {
// Only intercept raw frames; let normal Redux actions flow straight through.
if (action.type !== 'WS_FRAME_RECEIVED') return next(action);
const frame = action.payload as WSFrame;
// Already applied (replay after reconnect, or duplicate from buffer flush) — drop it.
if (frame.seq_id <= lastProcessedSeq) return;
if (frame.seq_id === lastProcessedSeq + 1) {
apply(next, frame); // exactly the next frame: apply now
drainContiguous(next); // then release any buffered frames the gap was blocking
} else if (pending.size < MAX_QUEUE_DEPTH) {
pending.set(frame.seq_id, frame); // arrived early: hold until predecessors land
if (pending.size > RESYNC_THRESHOLD) requestResync(next);
} else {
// Buffer is full and the gap never closed — bail out and force a clean snapshot.
requestResync(next);
}
};
function apply(next: Dispatch, frame: WSFrame) {
next({ type: frame.inner.type, payload: frame.inner.data, meta: { seq_id: frame.seq_id } });
lastProcessedSeq = frame.seq_id;
}
function drainContiguous(next: Dispatch) {
// Walk the buffer forward while each next seq_id is present, applying in order.
let nextSeq = lastProcessedSeq + 1;
while (pending.has(nextSeq)) {
const buffered = pending.get(nextSeq)!;
pending.delete(nextSeq);
apply(next, buffered);
nextSeq = lastProcessedSeq + 1;
}
}
function requestResync(next: Dispatch) {
pending.clear();
next({ type: 'WS_RESYNC_REQUESTED' }); // a saga/effect re-fetches the authoritative snapshot
}
// Call this from your reconnect handler — a new socket starts a new sequence space.
export function resetSequenceState() {
lastProcessedSeq = 0;
pending.clear();
}
Register it once when you build the store, and call resetSequenceState() from the socket’s reconnect path so a replayed stream starts from a known baseline rather than fighting the previous one:
import { configureStore } from '@reduxjs/toolkit';
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(wsSequenceMiddleware),
});
Validate frames at the boundary so a malformed seq_id can never advance the counter or poison the buffer. A runtime schema check is cheap relative to a corrupted store:
import { z } from 'zod';
const WSFrameSchema = z.object({
seq_id: z.number().int().positive(),
inner: z.object({ type: z.string().min(1), data: z.unknown() }),
});
// Parse in onmessage before dispatching WS_FRAME_RECEIVED; reject on failure.
Operational checklist #
- Server stamps a monotonic
seq_id -
resetSequenceState() - Reconnect handler requests an authoritative snapshot rather than replaying from
seq_id -
MAX_QUEUE_DEPTH - A sustained backlog above
RESYNC_THRESHOLD -
ws_queue_depth,ws_dropped_frames_total, andws_resync_total
FAQ #
Why not just deduplicate by payload hash instead of a sequence number? #
Hashing catches exact duplicates but cannot detect a missing frame or order a buffer. Two distinct updates to the same field hash differently yet still need ordering, and a dropped frame leaves a silent gap a hash can never see. A monotonic seq_id gives you dedup, gap detection, and reorder in one integer comparison.
Where should the sequence buffer live — middleware or the reducer? #
Keep it in middleware. Reducers must stay pure and synchronous; buffering is inherently stateful and time-dependent. Middleware is the documented seam for side-effecting logic between dispatch and the reducer, which is exactly where reordering belongs. The same boundary is the right place to hook rollback logic from optimistic UI rollback on WebSocket NACK.
Does this work with RTK Query or Redux-Saga? #
Yes. The middleware sits in front of either. With RTK Query, dispatch the unwrapped inner action and let your normal updateQueryData patches run. With Redux-Saga, emit WS_RESYNC_REQUESTED and let a saga takeLatest the snapshot fetch so overlapping resyncs collapse to one.
What if the gap never fills because a frame was genuinely lost? #
That is what RESYNC_THRESHOLD and MAX_QUEUE_DEPTH guard against. A frame lost on the server side (not just reordered) means the gap is permanent, so indefinite buffering would leak memory and freeze the UI. Crossing either bound abandons the buffer and pulls a fresh authoritative snapshot.
How does this relate to backend heartbeats and reconnect handling? #
The frontend buffer only mitigates ordering once a connection exists; it cannot help if the socket silently dies. Pair it with server-side liveness from Backend WebSocket Connection Management so dead connections are detected and replaced promptly, giving your reconnect-and-resync path a clean trigger.
Related #
- Optimistic UI rollback on WebSocket NACK — undo local changes the server rejects, the acknowledgement counterpart to ordering.
- State Sync & Optimistic Updates — the broader patterns for reconciling local and server state over a stream.
- React WebSocket Custom Hooks — connection-management hooks that feed frames into this pipeline.
- Backend WebSocket Connection Management — server-side heartbeats and reconnect handling that define when a resync fires.