WebSocket Backend Setup
What your own backend needs to implement to work with Flux's raw WebSocket adapter: an explicit SUBSCRIBE and UNSUBSCRIBE protocol, a catch up handshake that answers with full rows, asOf and tooOld, a reconciliation handshake, and a single state changelog that separates global rows from per user rows.
- 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 way to identify the user on connect, usually a signed token, so per user rows reach only their owner.
- A data source that can tell you when a row changes: DB triggers, change streams, or your own event bus.
- A single state changelog, shown in Step 8, so catch up and reconcile have authoritative data to query.
Everything on this page describes JSON messages sent over a plain WebSocket connection: action names, field shapes, and which frame triggers which client behavior. None of it requires Node. Node is shown as the complete worked example because it is 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, pushing events as they happen. Nothing on the client re requests this on a timer. Every live event you send must match one shape. The rules that matter most: every event is a full row, a delete carries just its id, and every event has an id and a timestamp on your server's clock.channel, at the top level. Protocol envelopes such as SYNC_COMPLETE carry an action instead and are handled inside the adapter.Install your server dependencies
npm install express ws cors dotenv mongoose jsonwebtoken. Python: pip install fastapi "uvicorn[standard]" websockets motor pyjwt. Add whatever driver your data source needs. None of that is WebSocket specific.Identify the user on the upgrade request
WebSocket cannot send custom headers, so put a short lived signed token in the connection URL and verify it during the upgrade, before any message is exchanged. A connection with no valid token is anonymous: it receives global rows only.When the client has anchors, the adapter appends ?syncAnchors= to the connection URL after your own parameters. You can ignore it. Catch up is answered by the SYNC_REQUEST handshake in Step 5, which is the only path that can prove coverage. Rows pushed eagerly from the URL cannot report an outcome, so this page no longer uses them.
A per channel registry with an audience filter
ownerId. With no owner the row is global and every subscribed socket receives it. With an owner the row is per user and only that user's sockets receive it.The connection handler: SUBSCRIBE, ack, sync, reconcile
{ action: 'SUBSCRIBE', channel, event } frame as soon as flux.register() runs, flushed the instant the socket opens, and an UNSUBSCRIBE frame on flux.unregister(). The handler below also answers the two request frames, SYNC_REQUEST and RECONCILE_REQUEST, always scoped to the connected user.The client only starts its catch up when it receives a { action: 'SUBSCRIBED', channel } frame back. A handler that registers the socket without sending it means catch up never runs, with nothing in your logs pointing at why.
What SYNC_COMPLETE has to prove
SUBSCRIBED ack arrives, engine.ts calls the adapter's sync(), which sends SYNC_REQUEST with the client's anchor and waits for the matching SYNC_COMPLETE. That answer decides whether Flux can claim the channel is up to date, so it must be honest. Each row needs an id and a timestamp. The adapter delivers every row through the channel's callback, and a row it cannot apply makes the whole catch up a failure rather than a partial success.An empty rows array is a real answer. It means "nothing changed since your anchor" and lets a quiet channel skip a refetch. Never send it as a fallback for an error. Send error instead.
Say tooOld when you cannot be complete. That covers an anchor older than your retention window and a result larger than your row limit. A partial delta presented as complete leaves rows missing forever.
Report asOf slightly in the past. The client uses it as its next anchor. Reporting a few seconds behind now means a change that was still committing is delivered again next time. Applying a row twice is harmless, missing one is not.
Reconciliation: confirming deletions the changelog missed
SYNC_COMPLETE can report. reconcile() is the safety net. The client sends the ids it has cached for a channel, in chunks of up to 5000, and your server answers with the ones that no longer exist. The handler is already in Step 5. Two rules apply: look ids up under the connected user, so a row the user can no longer see is correctly reported as gone, and when you cannot answer, report no deletions instead of all of them.The changelog: single state, global and per user
userId. A null value is a global row that every client may read. A string value is a per user row that only that user may read.An append only log grows forever and needs compaction. A single state changelog with a TTL index stays small and fast to scan. The tradeoff is that a client offline longer than the retention window for a row needs a refetch, and reconcile() covers the deletions. Retention is a pair: entries are purged after 14 days and catch up is answered tooOld past 13, so an answer never comes from a half purged window.
logChange records a change with its owner and returns the owner and timestamp to broadcast with. queryChangesSince returns only the caller's rows plus global rows. queryExistingIds checks existence under the caller. The registry at the top maps each table to its owner column, or to null for a global table.Registering a table with ownerField: null publishes every row to every client, signed in or not. Use it only for data that is genuinely the same for everyone, such as announcements or pricing. Everything else gets an owner field.
Wiring your data source to broadcast and log
logChange() so catch up has the entry, then calls broadcastToWs() with the owner and timestamp that came back, so a live client and a later catch up always agree. MongoDB Change Streams are shown as one concrete example. Updates are always sent as the full row, because the stream is opened with updateLookup.For Postgres, follow the Postgres and Supabase backend page: its trigger fills the changelog with the same owner rules, and its listener calls broadcastToWs(channel, event, ownerId) with the owner from the entry. MySQL can write a trigger to a small outbox table tailed by a polling loop. The client stays fully push based either way.
Mounting the server
Client store adapters: consuming the wire format
setState must handle a full array (bootstrap or a merged collection) and a single event object. Because every event is a full row, merging and replacing are both safe, and a delete always carries an id.For collection channels, Flux appends the new row to your existing array before calling setState, so the array can contain already shaped items next to one raw server row. If your store maps rows into a different shape, map every item in the array on the way in, not only the one you expect to be new.
Testing your endpoint
wscat. Install it with npm install -g wscat, then connect with a test token for a real user:SUBSCRIBED ack comes back:asOf, and tooOld: false. Use an anchor within the last 13 days. An anchor of 0, or one older than 13 days, should come back tooOld: true:"channel":"items" prints. Repeat with a second token for a different user, change a per user row for the first user, and confirm the second terminal receives nothing. Change a global row and confirm both do.Authorize channels, not only users. Knowing who is connected does not decide which channels they may subscribe to. Check the requested channel against what that user is allowed to read before registering the socket.
Dead connection detection. TCP does not always notice a client disappeared, for example a laptop lid closing. In Node track an isAlive flag per socket and use ws.ping() and pong on an interval. FastAPI and Starlette expose similar control at the ASGI server level.
Horizontal scaling. Raw WebSocket servers have no cross instance adapter. More than one process behind a load balancer needs your own shared pub and sub (Redis, NATS, and so on) so a change detected on instance A reaches a client connected to instance B. Carry the ownerId through it so the audience filter still applies.
Token lifetime. The token is verified once, at upgrade. A long lived socket keeps its identity after the token expires. Close and reopen sockets on a schedule that matches your session policy.
Origin checking. Raw WebSocket has no CORS enforcement. Check the origin header yourself during the upgrade. 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 handshake fails before it reaches your server.
One clock. Row timestamps and asOf must come from the same server clock. If several backend instances stamp rows, keep their clocks synced, or take timestamps from the database instead.
On the client side, this is the server the WebSocket tab in getting_started/react and getting_started/nextjs connects to. Nothing here needs to know about Zustand, Redux, Jotai, or Stunk, because the state manager choice lives entirely on the client. For the deeper explanation of sync anchors and delta catch up across every transport, see core_concepts/sync_anchors.