Socket.IO Backend Setup
What your own backend needs to implement to work with Flux's Socket.IO adapter: a per channel event bus with per user rooms, a sync 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 Socket.IO chosen as its realtime adapter.
- A backend server you control, in any language, with a Socket.IO server implementation (this page shows Node and Python side by side).
- A way to identify the user on connect, usually a signed token sent with the handshake, 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 7, so sync and reconcile have authoritative data to query.
Everything on this page describes Socket.IO event names and JSON payload shapes. None of it requires Node. Node is shown as the complete worked example because it is what most Flux Socket.IO setups use, but every step includes a Python (python-socketio) equivalent, and the same contract is just as implementable in Go, Ruby, Java, or any other language with a Socket.IO server library. If your backend already runs in one of those, translate the event names and payload shapes directly. Nothing about the adapter contract assumes a JavaScript runtime on the other end.
The contract you're building against
channel field in the payload. The Socket.IO event name is the routing mechanism. The adapter attaches a listener for the exact string `${channel}:${event}`, and your server emits under that same string. Global rows go to everyone, per user rows go to that user's room:You do not track which sockets are subscribed to which channel. Socket.IO already dispatches by event name, and rooms give you the per user audience for free. Broadcasting a global row is one io.emit(), and a per user row is one io.to('user:' + id).emit(). This is the one place Socket.IO is simpler than the raw WebSocket and SSE transports.
Install your server dependencies
npm install express socket.io cors dotenv mongoose jsonwebtoken. Python: pip install python-socketio fastapi "uvicorn[standard]" motor pyjwt. Add whatever driver your data source needs. None of that is Socket.IO specific.Identify the user on the handshake
auth option. Verify it in a connection middleware before the socket is accepted. A connection with no token is anonymous and receives global rows only, and an invalid token is rejected outright. When the socket connects, it joins a room named after its user, which is how per user rows reach it.The sync anchor handshake (secondary signal)
FLUX_SYNC_ANCHOR carrying the full anchor map. It is a bulk session signal, not the catch up mechanism. The per channel handshake is flux_sync_request in the next step.FLUX_SYNC_ANCHOR fires once per connection with the full anchor map, which is useful for server side logging or session tracking. flux_sync_request fires once per channel with that channel's anchor, and that is what drives row level catch up. Neither is gated on the anchor being non zero.
The authoritative handshake: flux_sync_request and flux_sync_complete
flux_sync_request with { channel, last_sync_anchor } and waits for a matching <channel>:flux_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, and a row the adapter cannot apply turns the whole catch up into 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 and the Socket.IO event handlers
flux_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, with flux_reconcile_request, and your server answers with the ones that no longer exist in <channel>:flux_reconcile_complete. Two rules apply: look ids up under the connected user, so a row the user can no longer see is 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.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.
Query helpers: sync and deletion check
logChange records a change with its owner and returns the owner and timestamp to emit with. queryChangesSince powers the sync handshake and returns rows, asOf and tooOld for one user. queryExistingIds powers reconciliation under the same user. Retention is a pair: entries are purged after 14 days and sync is answered tooOld past 13, so an answer never comes from a half purged window.Wiring your data source to emit and log
logChange() so sync has the entry, then emits with the owner and timestamp that came back: to the user's room for a per user row, to everyone for a global row. A live client and a later catch up therefore always agree. Updates are always 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 emits to io.to('user:' + ownerId) or to io.emit() depending on the owner in the entry.
Mounting the server
cors option lives on the SocketIOServer constructor, because Express's cors() middleware does not cover the Engine.IO handshake. The auth middleware is registered with io.use() before any handler runs.Client store adapters: consuming the wire format
setState handles full arrays (bootstrap, merged collections) and single event objects (live events and sync rows). 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 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.
Testing your endpoint
npm install --save-dev socket.io-client. Python: pip install "python-socketio[client]". Set TEST_TOKEN to a real user's token.items:flux_sync_complete with rows, an asOf, and tooOld false. Change the anchor to 0 and confirm it comes back tooOld true. Then change a row in your database and watch items:UPDATE print. Run a second copy with a different user's token, change a per user row for the first user, and confirm only the first copy prints it. Change a global row and confirm both do.Authorize channels, not only users. Because a global emit reaches every connected socket, only data meant for everyone should ever go through io.emit(). Anything per user must go to a room. A single wrong emit is a data leak.
Horizontal scaling. A single process can only emit to sockets connected to it, and rooms are per process too. More than one instance behind a load balancer needs a shared adapter, @socket.io/redis-adapter on Node or python-socketio's Redis or Kafka manager on Python. With it, io.to('user:' + id) reaches that user on any instance.
Sticky sessions. Without a shared adapter, Socket.IO's long polling fallback needs every request from one client to land on the same process. Configure sticky sessions at your load balancer, or force transports: ['websocket'] on the client to sidestep it.
Token lifetime. The token is verified once, at the handshake. A long lived socket keeps its identity and room membership after the token expires. Disconnect sockets on a schedule that matches your session policy, and remove a user's room membership when their access changes.
Running alongside raw WebSocket. If you also run a raw ws server on the same HTTP port, create it with new WebSocketServer({ noServer: true }) and route upgrade events manually in server.on('upgrade'), passing /socket.io/ paths through untouched. Otherwise Engine.IO and ws fight over the upgrade handshake.
Reverse proxy upgrade. Behind Nginx the location block needs proxy_set_header Upgrade $http_upgrade; and proxy_set_header Connection "upgrade";, or connections silently fall back to slower, chattier long polling.
One clock. Row timestamps and asOf must come from the same server clock. If several backend instances stamp rows, keep their clocks synced.
On the client side, this is the server the Socket.IO 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.