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:- 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 theApiKey./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. - Spin-up. Once the upgrade succeeds, the server answers with a
helloframe carrying the tenant identifier and the server’s clock time. The client sendssubscribeframes; the server answerssubscribedfor each one and lazily binds a Redis subscription to the object’s channel (sync:<tenant-schema>:<kind>:<name>) on the first subscribe. - 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.
- Release / spin-down. Either side can close the socket. A client
closes politely by sending
unsubscribeframes then callingclose(); 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 code1001(going away), so a well-implemented client knows to reconnect rather than retry forever.
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: thews-* (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’sSUBSCRIBEto the exact object channel gives the gateway a fan-in: the REST writer’sPUBLISHon 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 RedisPUBLISHto 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 aGET— converges on a follow-upGETfor 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 bumpedrevisionthe client can compare against its base.
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
unsubscribeframes a client sends just before closing are processed and acknowledged before the server reclaims the slot, so the server’sunsubscribedacknowledgement 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_connectionserror.
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 sendunsubscribe — still behave:
- The server’s idle timeout cleans up any connection the client abandoned, inside the heartbeat window, without the operator needing to do anything.
- 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.
- 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.
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 therevision 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
- The Sync real-time shared-state model — the four object kinds, the convergence loop, TTL, and the permission split
- Sync real-time state guide — the end-to-end REST + WebSocket walkthrough with a runnable example
- Sync API reference — endpoint-by-endpoint request and response shapes
- WebSocket delivery semantics — how Orbit’s other push channels document delivery
- Tenant isolation — the scoping guarantee every Sync object inherits