Skip to main content

Sync: WebSocket claim, spin-down, and the flush window

The durable Sync object families (Documents, Maps, Lists) and the ephemeral Stream all converge through two planes: a REST write path on the API and a WebSocket notification plane on /api/v1/ws/sync. The polling style a client starts with — the occasional GET to catch up — gives way to a live socket the moment the client wants real-time change events instead of a pull. This page covers that hand-off at concept level: the claim lifecycle a socket goes through, the order of operations that keeps every subscriber seeing each change event exactly once, how long-running clients hold a connection open without it being spun down prematurely, the contract for the endpoint, and how the socket interacts with the rest of the Sync object model. For the full object model, see the Sync real-time shared-state model; for the request-by-request walkthrough, the Sync real-time state guide; for the raw frames, the Sync API reference.

1. Order of operations: claim, spin-up, release, spin-down

Every WebSocket Sync session follows the same claim → spin-up → release → spin-down lifecycle:
  1. Claim. The client opens the upgrade on GET /api/v1/ws/sync. The gateway authenticates the upgrade request (API key in a header, or a session token via the ApiKey. / Bearer. subprotocol for browsers) and resolves the tenant. Claiming the slot binds the socket to exactly one tenant — subscriptions can only ever attach to objects in that tenant.
  2. Spin-up. Once the upgrade succeeds, the server answers with a hello frame carrying the tenant identifier and the server’s clock time. The client sends subscribe frames; the server answers subscribed for each one and lazily binds a Redis subscription to the object’s channel (sync:<tenant-schema>:<kind>:<name>) on the first subscribe.
  3. Steady state. The socket holds the subscription and relays every published change event the moment the REST write commits, until either side spins the connection down.
  4. Release / spin-down. Either side can close the socket. A client closes politely by sending unsubscribe frames then calling close(); the server closes sockets on a graceful shutdown, on an idle timeout, on a policy violation, or on a per-tenant connection limit. On shutdown the server sends the standard WebSocket close code 1001 (going away), so a well-implemented client knows to reconnect rather than retry forever.
The spin-up work happens AFTER the claim succeeds — a client must wait for the server’s hello before it sends subscribe, or the frames are dropped. Claiming the slot, then subscribing to the objects the client cares about, is the entirety of the lifecycle.

The four phases, end to end

2. Exactly-once delivery to every subscriber

Two queues cooperate so a subscriber sees each event exactly once: the ws-* (WebSocket) socket-level queue and the x-poll-* (client-side catch-up / rehydration) pair, even though the wire format is a single JSON frame.
  • The ws-* side is the socket’s subscriber set. Redis’s SUBSCRIBE to the exact object channel gives the gateway a fan-in: the REST writer’s PUBLISH on the channel reaches every active subscription, and the gateway relays the frame verbatim to the client that owns the subscription. Each active socket receives the published event at most once, because a Redis PUBLISH to a channel delivers the message once per subscriber — and because the gateway tracks the socket’s channel set so it never double-subscribes.
  • The x-poll-* side is the client’s REST catch-up fallback. A client that missed an event — because the socket disconnected, because it subscribed after the publish committed, or because the frame arrived before the client had finished applying a GET — converges on a follow-up GET for the current object state rather than on replaying WebSocket history. That rehydration closes the “exactly once” gap: the client eventually applies the latest revision of the object, and it never double-applies an event because each event carries the bumped revision the client can compare against its base.
In practice the client should treat the WebSocket frame as a hint and the REST GET as the convergence point. A stream, which stores nothing, is the exception: if the client missed a stream.message frame, no catch-up exists — the message is gone. That asymmetry is why choosing between a durable kind and a stream matters.

3. The grace window for long-running clients

Clients that do real work between events — for example a mobile app that applies a frame, renders, and only then subscribes to another object — need a window between “we have not heard from you” and “we reclaim your slot.” The gateway holds a long-running connection open with two mechanisms, and the flush window is the bounded tail the client must finish inside:
  • Heartbeat. The server pings the socket every 30 seconds; any inbound frame — including a client ping — resets the idle clock. A client is not required to send application frames to stay alive, only to answer the server’s pings.
  • Idle timeout. The server closes a socket after roughly 90 seconds with no pong and no inbound message — three missed heartbeat cycles. Missing pings past the grace window flushes the connection.
  • Flush window. Any unsubscribe frames a client sends just before closing are processed and acknowledged before the server reclaims the slot, so the server’s unsubscribed acknowledgement for each frame is the signal that the spin-down flushed cleanly. The window the client must finish its cleanup inside is bounded by the heartbeat interval — close after those acknowledgements arrive, not immediately after sending them, so the server never has to guess whether the client simply lost a race with the idle timer.
  • Connection limits. A tenant may hold at most 50 concurrent sockets, and a single socket at most 200 subscriptions. Long-running clients should respect the caps: the server releases the connection slot when the socket closes, so reusing a long-lived socket is generally cheaper than opening a new one per subscription — but a client that churns connections must do so inside the per-tenant cap, or the claim returns a too_many_connections error.
The flush window is intentionally generous for legitimate clients and short for crashed ones: a client that disconnects without closing releases its slot when the idle timeout fires or when the per-connection TTL on the cluster counter expires, so a crashed client drops off the roster without an operator cleanup pass.

4. Backward compatibility: clients that do not opt into flush still behave

The flush-window opt-in is a graceful-close nicety, not a requirement. Clients written before the spin-down window existed — clients that just close the socket, clients that never send unsubscribe — still behave:
  1. The server’s idle timeout cleans up any connection the client abandoned, inside the heartbeat window, without the operator needing to do anything.
  2. The per-tenant connection counter reclaims the slot via the TTL on the cluster counter even when the close frame never arrived, so a crashed client does not permanently consume a slot.
  3. The unsubscribe-then-close flush pattern only exists to give a deliberate client a definitive acknowledgement; the REST + TTL-based cleanup path handles every other case, so no existing integration needs a code change.
Opt into the explicit spin-down when a client disconnects as part of a controlled navigation (a page unload, a user signing out) and operator observability matters — the acknowledgement frames make the end of the connection legible. Skip it when the client would close anyway; the server handles both.

5. Endpoint contract

The gateway accepts API-key auth in a header (server-to-server) or the ApiKey. / Bearer. subprotocol token (browser). After the upgrade, subscribes and unsubscribes are ordinary frames — no further REST call is required to manage the subscription set.

6. Interaction with the other Sync surfaces

The claim/spin-down lifecycle is the cutover between the two halves of the Sync real-time shared-state model. The object-kind page covers WHAT the client converges on (durable vs ephemeral objects, and the revision contract that lets you detect a stale base); this page covers HOW the client holds the socket that carries those convergence frames. The REST write path and the rehydration GET both live on /api/v1/sync — the WebSocket gateway exposes only the notification plane, so every operational action (write, catch-up) is an ordinary REST call against the same Sync object model. A long-poll → WebSocket hand-off happens exactly once per client session: the long-poll style (GET in a retry loop) hands off to the socket at the hello frame, and the socket hands back to the REST catch-up path after a spin-down. There is no second WebSocket handshake — once a client has upgraded, the subscription protocol is the only real-time surface it needs.

See also