WebSocket Backend Setup
What your own backend needs to implement to work with Flux's raw WebSocket realtime adapter — an explicit SUBSCRIBE/UNSUBSCRIBE protocol, a delta catch-up handshake backed by a single-state changelog, and a reconciliation handshake your server has to speak, since plain WebSocket has no built-in channel concept of its own.
- An existing Flux app already wired up on the client side, with WebSocket chosen as its realtime adapter.
- A backend server you control, in any language, with WebSocket support (this page shows Node and Python side by side).
- A data source that can tell you when a row changes — DB triggers, change streams, or your own event bus.
- A changelog/CDC mechanism so sync() and reconcile() have authoritative data to query — shown in Step 8 below.
Everything on this page is a description of JSON messages sent over a plain WebSocket connection — action names, field shapes, which frame triggers which client-side behavior. None of it requires Node. Node is shown as the complete worked example because it's what most Flux WebSocket setups use, but every step includes a Python (FastAPI) equivalent, and the same contract is just as implementable in Go, Ruby, Java, or anything else with WebSocket server support. If your backend already runs in one of those, translate the frame shapes directly — nothing about the adapter contract assumes a JavaScript runtime on the other end.
The contract you're building against
WebSocket connection and expects it to stay open indefinitely, pushing events as they happen — nothing on the client re-requests this on a timer. Every event you send needs to match one shape:channel — at the top level. Protocol envelopes like SYNC_COMPLETE are handled internally by the adapter and reshaped into this same normalized event before reaching your store.Install your server dependencies
npm install express ws cors dotenv mongoose. Python: pip install fastapi "uvicorn[standard]" websockets motor. Either way, add whatever driver your data source needs — none of that is WebSocket-specific.A per-channel client registry
Speaking the SUBSCRIBE / UNSUBSCRIBE protocol — and the required ack
{ action: 'SUBSCRIBE', channel, event } frame the moment flux.register() runs on the client, flushing it the instant the socket opens — and sends the matching UNSUBSCRIBE frame on flux.unregister().The client's onChannelSubscribed callback — which gates the entire delta catch-up chain Flux runs on reconnect (Step 6 below, plus the TTL cache-clock refresh in engine.ts) — only fires when the client receives a { action: 'SUBSCRIBED', channel } frame back. A handler that registers the client without sending this frame means catch-up silently never runs, with nothing in your logs pointing at why.
Eager catch-up: sync anchors embedded in the connection URL
SyncAnchorMap directly into the connection URL as a ?syncAnchors= query parameter, built fresh at connect time. This is a best-effort head start, not the mechanism that actually gates the client's cache refresh — see Step 6 for that.A raw WebSocket has no built-in auth handshake object or guaranteed-first message — the very first thing your server can reliably read is the HTTP upgrade request itself, so encoding the anchor map into the connection URL is the only pattern that works before any message has been exchanged.
The authoritative handshake: SYNC_REQUEST / SYNC_COMPLETE
SUBSCRIBED ack from Step 4 reaches the client, engine.ts calls the adapter's sync() method, which sends a SYNC_REQUEST frame and waits for a matching SYNC_COMPLETE. This — not the eager URL param above — is what actually gates whether Flux refreshes its local cache clock, and it needs to carry real delta rows.The client-side adapter reads frame.rows and forwards each one through the channel's registered callback before resolving. A SYNC_COMPLETE with an empty or missing rows array resolves successfully but delivers zero data — the client will warn about this in the console.
Reconciliation: confirming deletions the delta log missed
SYNC_COMPLETE can report. reconcile() is the safety net — the client sends its full list of locally-cached IDs for a channel, and your server confirms which of them no longer exist.The changelog: single-state delta storage
An append-only log grows forever and requires compaction. A single-state changelog bounded by a TTL index (14 days in the example above) stays small and fast to scan. The tradeoff: a client offline longer than the TTL for a specific row needs reconcile() as the fallback — same tradeoff the Supabase retention sweep documents.
Wiring your data source to broadcast and log
logChange() so the changelog has data for future sync/reconcile queries, and call broadcastToWs() so currently-connected live clients get the event immediately. MongoDB Change Streams shown below as one concrete example.Postgres: a trigger that runs NOTIFY on insert/update/delete, with a long-lived LISTEN connection translating each notification into the same broadcastToWs() call — and a small changelog table updated by the same trigger. MySQL: a trigger writing to a small outbox table, tailed by a polling loop on your own infrastructure. The client stays fully push-based either way.
Mounting the server
Client store adapters: consuming the wire format
setState must handle two intake shapes: a full array (bootstrap / SYNC_COMPLETE batch) and a single delta object (live stream). The pattern is identical across Zustand, Redux, and Jotai.If your UI creates optimistic placeholders (e.g., a task with status: 'optimistic_pending'), the setState logic can resolve them when the confirmed server record arrives — typically by matching on a dedupe field like title or name before falling back to a standard id-based upsert. See the Tasks and Items store implementations in the demo app for concrete examples.
Testing your endpoint
wscat. Install it with npm install -g wscat, then connect:SUBSCRIBED ack back (Step 4) before moving on:"channel":"items" print immediately in the wscat terminal. Then disconnect, wait a few seconds, reconnect, and verify that the SYNC_COMPLETE frame carries the changes you missed.Dead connection detection — TCP doesn't always notice a client disappeared (a laptop lid closing, a mobile network drop). In Node, track an isAlive flag per socket and use ws.ping()/pong on an interval; FastAPI/Starlette exposes similar ping/pong control at the ASGI server level.
Horizontal scaling — raw WebSocket servers have no built-in cross-instance adapter. Running more than one server process behind a load balancer requires your own shared pub/sub (Redis, NATS, etc.) so a change detected on instance A reaches a client connected to instance B.
Auth on the handshake — the browser's native WebSocket constructor can't attach custom headers, so bearer tokens don't work here the way they might on a normal fetch. Use a signed short-lived token in the connection query string (alongside syncAnchors) and verify it during the upgrade, before accepting the connection.
Origin checking — raw WebSocket has no CORS enforcement at all. Check the request's origin header yourself during the upgrade if you need to reject connections from unexpected origins — a regular CORS middleware only covers your normal HTTP routes.
Reverse proxy upgrade headers — behind Nginx, the location block needs proxy_set_header Upgrade $http_upgrade; and proxy_set_header Connection "upgrade";, or the upgrade handshake fails before it reaches your server at all, regardless of language.
On the client side, this is the server the WebSocket tab in getting-started/react and getting-started/nextjs connects to — nothing on this page needs to know anything about Zustand, Redux, Jotai, or Stunk; the state-manager choice lives entirely on the client. For the deeper explanation of sync anchors and delta catch-up across every transport (not just WebSocket), see core-concepts/sync-anchors.