> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

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

> How a Devotel Orbit Sync client moves from an intermittent poll to a live WebSocket subscription — the claim lifecycle (connect, subscribe, spin-up), the order of operations that keeps every subscriber seeing each event exactly once, the grace window the server holds open after a spin-down, and the endpoint contract for claim and release on `/api/v1/ws/sync`.

# 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](/concepts/sync-realtime-state-model);
for the request-by-request walkthrough, the [Sync real-time state guide](/guides/sync-realtime-state);
for the raw frames, the [Sync API reference](/api-reference/sync).

## 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

```mermaid theme={null}
sequenceDiagram
    participant Client
    participant API as Sync REST API
    participant GW as Sync WS gateway

    Client->>GW: GET /api/v1/ws/sync (upgrade)
    GW-->>Client: hello (tenant, ts)
    Client->>GW: subscribe(kind, name)
    GW-->>Client: subscribed
    API->>GW: REST write commits, publishes event
    GW-->>Client: change event (kind, name, revision, data)
    Client->>API: GET state (rehydrate after reconnect)
    Client->>GW: unsubscribe / close
    GW-->>Client: close 1000 (normal) or 1001 (going away)
```

## 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

| Operation           | Path / wire frame                                                       | Purpose                                                  |
| ------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------- |
| Claim               | `GET /api/v1/ws/sync` (upgrade)                                         | Authenticate, resolve the tenant, claim a socket slot    |
| Spin-up handshake   | server frame `hello`                                                    | Bind the connection and expose the server clock          |
| Subscribe           | client frame `subscribe` + `kind` + `name` → `subscribed`               | Attach a socket to one object channel                    |
| Unsubscribe         | client frame `unsubscribe` + `kind` + `name` → `unsubscribed`           | Detach the socket from one object channel                |
| Heartbeat           | server `ping` every 30 s; client `pong` resets the idle clock           | Keep the connection's grace window from expiring         |
| Spin-down / release | WebSocket close frame, code `1000` (client) or `1001` (server shutdown) | Close the socket and release the slot                    |
| Catch-up            | `GET /api/v1/sync/<kind>s/<name>` REST                                  | Rehydrate the object after a spin-down or a missed frame |

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](/concepts/sync-realtime-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

* [The Sync real-time shared-state model](/concepts/sync-realtime-state-model) —
  the four object kinds, the convergence loop, TTL, and the permission split
* [Sync real-time state guide](/guides/sync-realtime-state) — the end-to-end
  REST + WebSocket walkthrough with a runnable example
* [Sync API reference](/api-reference/sync) — endpoint-by-endpoint request
  and response shapes
* [WebSocket delivery semantics](/concepts/webhook-delivery-semantics) —
  how Orbit's other push channels document delivery
* [Tenant isolation](/concepts/tenant-isolation) — the scoping guarantee
  every Sync object inherits
