Adapters

WebSocket

Point Flux at any backend that speaks raw WebSocket and it takes care of turning that connection into live store updates — a single multiplexed socket, reconnect handling, and catch-up after time offline all come for free once the adapter is wired in.

Prerequisites
  • A backend accepting WebSocket connections — any language or framework
  • @tsworldtech/flux and @tsworldtech/flux-websocket installed
1

Create the adapter

createWebSocketAdapter() accepts a config object or URL string function. Unlike the SSE adapter, this connection is multiplexed — one raw socket carries every registered channel, distinguished by the SUBSCRIBE protocol described below rather than by separate connections.
typescript
1import { createFlux } from '@tsworldtech/flux'
2import { createWebSocketAdapter } from '@tsworldtech/flux-websocket'
3
4const flux = createFlux({
5 adapter: createWebSocketAdapter({
6 url: process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:3001',
7 }),
8})
2

Register a store

channel and event are sent to your backend as part of a subscription message the moment this registration runs — there's no separate "connect" step to call yourself.
typescript
1flux.register({
2 store: useItemsStore,
3 channel: 'items',
4 event: 'UPDATE',
5 idbKey: 'items:all',
6 ttl: 'medium',
7 ingestionType: 'COLLECTION_ALL',
8 diffBeforeUpdate: true,
9 hydrateState: (store, data) => {
10 // store.getState().setState handles both array hydrations and single delta frames
11 store.getState().setState(data)
12 },
13})
14
15// Sends a SUBSCRIBE frame for the 'items' channel the moment this
16// registration runs, and an UNSUBSCRIBE frame the moment it's torn down.
3

The SUBSCRIBE / UNSUBSCRIBE protocol

Because plain WebSocket has no built-in channel concept the way Socket.IO does, the adapter invents a small protocol on top of it. Your server must receive the subscribe frame and respond with a SUBSCRIBED acknowledgment frame:
typescript
1// 1. Sent automatically the moment flux.register() runs:
2{ "action": "SUBSCRIBE", "channel": "items", "event": "UPDATE" }
3
4// ⚠️ REQUIRED SERVER ACKNOWLEDGMENT: Backend must respond with a SUBSCRIBED frame.
5// Without this ack, delta catch-up and cache clock refreshes are never triggered:
6{ "action": "SUBSCRIBED", "channel": "items" }
7
8// 2. Sent automatically when flux.unregister() runs or component unmounts:
9{ "action": "UNSUBSCRIBE", "channel": "items", "event": "UPDATE" }
Do not skip the SUBSCRIBED acknowledgment

Flux's client-side adapter waits for the { action: 'SUBSCRIBED', channel } frame before executing the authoritative SYNC_REQUEST catch-up handshake and updating the cache freshness clock. Omitting this acknowledgment will prevent automatic delta sync from executing upon reconnect.

4

Protocol envelopes: SYNC and RECONCILE

After subscription is acknowledged, the adapter handles catch-up and deletion reconciliation using specific protocol envelopes:
typescript
1// ── Authoritative Sync Handshake ──────────────────────────────────────────
2// Sent by adapter on channel subscription to pull changes since last_sync_anchor:
3{ "action": "SYNC_REQUEST", "channel": "items", "last_sync_anchor": 1718000000000 }
4
5// Backend returns historical delta rows:
6{
7 "action": "SYNC_COMPLETE",
8 "channel": "items",
9 "rows": [
10 { "op": "UPDATE", "data": { "id": "662f...", "price": 42 }, "timestamp": 1718000001234 }
11 ]
12}
13
14// ── Deletion Reconciliation Handshake ─────────────────────────────────────
15// Sent by adapter to verify local IDs that might have been deleted offline:
16{ "action": "RECONCILE_REQUEST", "channel": "items", "localIds": ["id1", "id2"] }
17
18// Backend returns missing IDs:
19{ "action": "RECONCILE_COMPLETE", "channel": "items", "deletedIds": ["id2"] }
5

What incoming live frames need to look like

Every live event message your backend pushes down the socket needs one extra field at the top level beyond the normal event shape — channel — since this is what the adapter uses to route the message to the right registration.
typescript
1// The adapter expects every live incoming message to carry one extra field
2// at the top level, alongside the normal event fields — "channel":
3{
4 "channel": "items",
5 "entity": "item",
6 "id": "662f...",
7 "op": "UPDATE",
8 "data": { /* ...row or sparse diff */ },
9 "timestamp": 1718000001234,
10 "source": "my-backend"
11}
12
13// Routing happens on "channel" alone — the adapter keeps exactly one
14// callback per channel. Unlike the Socket.IO adapter, "event" inside the
15// payload isn't used for routing; only the outer "channel" key is.
6

One socket, not one per channel

Multiplexed over a single connection

Unlike SSE (one connection per channel) or Socket.IO (one connection, native event dispatch), the WebSocket adapter opens a single raw connection and multiplexes every registered channel over it using the SUBSCRIBE/UNSUBSCRIBE protocol above. Registering five channels still means one socket, not five.

7

Reconnects and catch-up are automatic

You don't write any of this yourself

You never need to write reconnect logic yourself. If the connection drops, Flux reopens it automatically with backoff, re-sends SUBSCRIBE frames for every active channel, encodes eager sync anchors directly in the connection URL (?syncAnchors=...), and initiates authoritative SYNC_REQUEST / RECONCILE_REQUEST handshakes once re-subscribed.

8

Any backend that speaks WebSocket

Not tied to any particular server stack

Any backend able to accept a WebSocket upgrade and speak plain JSON frames works here — Node, Python, Go, Rails, whatever you already run. There's no required framework; the contract is just the SUBSCRIBE/UNSUBSCRIBE/SYNC frames going out and the channel-tagged event frames coming back.

9

Authentication

No custom headers on the handshake

The browser's native WebSocket constructor can't attach custom headers either, the same limitation EventSource has. A short-lived signed token appended to the connection URL as a query parameter, verified by your backend during the upgrade, is the common pattern here.

Everything else — reconnecting after a dropped connection, making sure only one browser tab holds the live socket at a time, and catching back up on whatever changed while you were offline — happens automatically once the protocol above is implemented correctly on your backend. There's nothing further to configure on the client for a standard setup.

Building the server side of this contract from scratch? See getting-started/websocket for a full worked example. For what happens during reconnect specifically, see core-concepts/sync-anchors and core-concepts/activity-bus.