SSE Backend Setup
What your own backend needs to implement to work with Flux's Server Sent Events adapter: one multiplexed HTTP stream for every channel, a backfill per channel that ends with a marker, an optional POST reconciliation endpoint, 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 SSE chosen as its realtime adapter.
- A backend server you control, in any language, capable of holding an HTTP response open and writing to it incrementally (this page shows Node and Python side by side).
- A way to identify the user on the stream request, a token in the query string or a same origin cookie, 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 6, so backfill and reconciliation have authoritative data to query.
Everything on this page describes plain text HTTP output: required headers, a changelog query, and lines of text streamed per event. None of it requires Node or a dedicated SSE library. Node is shown as the complete worked example because it is what most Flux SSE setups use, but every step includes a Python (FastAPI) equivalent, and the same wire format is just as producible from Go, Ruby, Java, or anything else that can hold an HTTP response open and write to it. If your backend already runs in one of those, write the same headers and the same "event, data, blank line" text. Nothing about it assumes a JavaScript runtime on the other end.
The contract you're building against
EventSource that carries every subscribed channel, so a device holds a single stream however many channels are registered. Every row you send down it, live or backfill, must match one shape. The rules that matter most: each message names its channel, each row is a full record, a delete carries just its id, and every row has an id and a timestamp on your server's clock.Install your server dependencies
npm install express cors dotenv mongoose jsonwebtoken, plus cookie-parser if you use the cookie. Python: pip install fastapi "uvicorn[standard]" motor pyjwt. No SSE specific package is required. StreamingResponse is built into FastAPI and Express streams responses natively.Identify the user on the stream request
EventSource cannot set custom headers, so use a short lived token in the query string, or an HttpOnly cookie when the API is same origin and the adapter is created with { withCredentials: true }. A request with no valid token is anonymous and receives global rows only.A per channel registry with an audience filter and a backfill buffer
ownerId: with none the row is global and every client receives it, with an owner only that user's streams do.The stream route: register, backfill, marker, live
tooOld.Always register the client before running the backfill query. If you query first and register second, a change that commits while the query is in flight is neither in the backfill nor delivered live, and the event is lost. Registering first and buffering fixes both problems.
A marker with no rows before it means "nothing changed" and the client would mark the channel up to date. If a backfill query throws, log it and send no marker for that channel. The adapter times out after 30 seconds and reports a failure, the gap stays open, and the client refetches. That is the correct result.
The channel list and anchors travel in the URL. Browsers and proxies start rejecting very long URLs around 7000 characters, and the adapter logs a warning when it gets close. Use short channel names, or register fewer channels on this adapter.
The changelog: single state, global and per user
op: 'DELETE' row during backfill, which a query on the live collection can never do. The schema enforces single state: at most one entry per row per table, replaced by upsert. Each entry also records its audience in 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: backfill and deletion check
logChange records a change with its owner and returns the owner and timestamp to broadcast with. queryChangesSince powers the backfill 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 backfill is answered tooOld past 13, so an answer never comes from a half purged window.Reconciliation endpoint (HTTP POST)
reconcileUrl you give it, in chunks of up to 5000 ids, and expects { deletedIds } back. Without a reconcileUrl, reconcile is simply not attached. The route identifies the user the same way the stream does, and reports no deletions when it cannot answer.Wiring your data source to broadcast and log
logChange() so backfill has the entry, then calls broadcastToSse() with the owner and timestamp that came back, so a live client and a later backfill 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 calls broadcastToSse(channel, 'UPDATE', event, ownerId) with the owner from the entry.
Mounting the server
Client store adapters: consuming the wire format
setState handles full arrays (bootstrap, merged collections) and single event objects (live frames and backfill 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
curl and the unbuffered flag. Use a test token for a real user and an anchor within the last 13 days:: connected, then any backfill rows, then an event: flux:backfill block with an asOf for each channel. Change the anchor to 0 and confirm the marker comes back with tooOld set to true. Then change a row in your database and watch an event: UPDATE block print. Repeat with a second user's token, change a per user row for the first user, and confirm the second terminal prints nothing. Change a global row and confirm both do.Authorize channels, not only users. Knowing who opened the stream does not decide which channels they may read. Check each requested channel against what that user is allowed to see before registering the client.
Reverse proxy buffering. Behind Nginx set proxy_buffering off; for the stream location alongside the X-Accel-Buffering: no header. Otherwise proxies hold frames until the connection closes.
Connection limits. Browsers allow about 6 concurrent HTTP/1.1 connections per origin. This design uses one stream per device, but serve your API over HTTP/2 anyway so other requests are never starved.
Token lifetime. The token is verified once, when the stream opens. A long lived stream keeps its identity after the token expires. Close and reopen streams on a schedule that matches your session policy.
Adding a channel reopens the stream. An EventSource URL cannot change, so registering a new channel makes the adapter reopen the stream, debounced into one reopen per burst. Each reopen runs a backfill again, which is why backfill must be cheap and idempotent.
Worker process count. Persistent connections mean a broadcast must reach every server process. Use Redis pub and sub to bridge instances when scaling across workers or containers, and carry the ownerId through it so the audience filter still applies.
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 SSE tab in getting_started/react and getting_started/nextjs connects to. State management remains fully decoupled on the client. For deeper details on sync anchors and delta catch up across transports, see core_concepts/sync_anchors.