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:

  1. 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 onmessage handler fires many times in a single microtask burst — far faster than any debounce assumes.
  2. 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.
  3. 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.

Sequence-aware WebSocket to Redux pipeline Frames pass through a sequence gate that drops duplicates, buffers gaps, and releases in-order frames to reducers. WebSocket onmessage burst Sequence gate dedup + reorder Drop duplicate Buffer gap hold until filled Redux reducer

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 #

The middleware sits in one place and does three jobs, and separating them is what keeps the reducer pure.

Where each responsibility lives The socket middleware owns all connection side effects, action creators translate frames into typed actions, and reducers and selectors stay pure with no knowledge that a socket exists. Where each responsibility lives Socket middleware owns the connection, its lifecycle and reconnects side effects Action creators translate frames into typed actions pure Reducers apply actions to state — no socket knowledge pure Selectors derive view state, memoised per entity pure Components subscribe narrowly, never to the whole slice render A reducer that knows about a socket is a reducer you cannot test without one
One layer holds every side effect. Everything above it is ordinary, testable Redux.
Dispatches per second, batched and unbatched Dispatching every incoming message drives store notifications at the message rate, while batching into one dispatch per animation frame caps them at the display refresh rate. Dispatches per second, batched and unbatched one dispatch per message versus one per animation frame Dispatches without batching With frame batching 0/s 50/s 100/s 150/s 20/s 20 msg/s 60/s 30/s 60 msg/s 45/s 150 msg/s
Every dispatch notifies every subscriber. Batching is what keeps that cost bounded by the display rather than by the feed.

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.

Should the socket live in middleware or in a component? #

Middleware, for anything beyond a single screen. A connection owned by a component dies when that component unmounts, which makes navigation destroy and rebuild the socket; a connection owned by middleware lives for the store’s lifetime and can be shared by every screen. The trade is that middleware needs explicit connect and disconnect actions, which is a small price for a connection whose lifetime you control.

How do I avoid re-rendering the whole list on every message? #

Normalise the state and select per entity. A slice holding byId and allIds lets a component subscribe to one entity’s slice of the store, so a message about entity A leaves the components watching B through Z untouched. Replacing a whole array on every message is what makes memoisation useless — every child’s props changed, so nothing can be skipped.

Back to WebSocket State Sync and Optimistic Updates