Skip to article
PlanToCodeDocsGet the app

HandbookRelay & accounts

Device routing, reconnect, and recovery

Registration and discovery, the reconnect grace, what a reconnecting phone reloads, how timeline events reach only interested phones, push registration, and why the relay runs as one process.

Checked against the source on 17 September 2026

On this page

Registration and discovery

ClientWebSocket headersCross-checked against
DesktopX-Client-Type: desktopA desktop device row the same account registered over HTTP.
PhoneX-Client-Type: mobile, plus X-Target-Desktop-Device-ID for the selected desktopA mobile device row; the target desktop is read from the header or payload on every registration, never from a stored session.

Reconnect grace and the reconnect snapshot

A closed desktop socket ends its requests, while a closed phone socket ends its lease
A closed desktop socket ends its requests, while a closed phone socket ends its leaseThe relay treats the two sides differently. When the desktop socket closes, requests recorded for that connection are answered with -32011 at once, new requests get -32011 while the desktop is reconnecting, and only its presence waits 90 seconds before it counts as offline and requests get -32010. When a phone socket closes, its interest lease ends at once and the desktop stops sending timeline frames, while the phone’s pending request stays recorded. The phone registers again, gets the reply, sends its lease again, and catches up from the latest window.
When the desktop socket closes
When a phone socket closes
Desktop
Relay
Phone
socket closes
reconnecting
offline
-32011 at once
-32011
-32010
0 s
30 s
60 s
90 s
120 s
none sent
frames sent
lease ends at once
request kept
socket closes
registers, sends its lease
reply
latest window
not to scale
  • Desktop requests-32011 · -32010

    Requests recorded for the closed connection get -32011, “Desktop is reconnecting”, at once, and so does every new request during the wait. After it, new requests get -32010. The relay counts a silent socket as closed only after 180 seconds.

  • Reconnect waitreconnect_timeout

    It holds presence only, and phones see the desktop as reconnecting. At 90 seconds the relay marks it offline and broadcasts device-status disconnected, unless a new connection registered first.

  • Phone requests(user, desktop, id)

    The record outlives the phone’s socket, so a reply that arrives after the phone registers again is delivered. A final reply that lands during the gap is dropped, and the phone’s call ends at its own timeout.

  • Phone leasechat:timeline-interest

    Bound to the phone’s connection, so it ends when the socket closes and nothing from the gap is kept. The phone sends it again once the relay answers registered or resumed, pings the desktop, and loads the latest window.

A client that registers with its resume token still passes the protocol and device-ownership checks, and the answer says resumed and keeps the session ID. A failed resume answers invalidResume, and the client registers again without the token. The relay session record behind the token lives 24 hours, is touched on every RPC, and is invalidated on logout. iOS keeps the token in the keychain.

Offline, reconnecting, and timeout errors stay distinct, and only errors marked retryable permit a transport retry. A timeout describes the reply the caller did not get. It says nothing about whether the mutation ran, so the client follows the operation’s recovery path before it issues new work.

StateMeaning and next step
ReconnectingThe phone is restoring its server connection and then re-verifying the desktop. Give it a moment.
Offline or disconnectedCheck the desktop app, the computer’s sleep state, the account, the region, and the network.
Request timed outThe reply did not arrive in time. The request may still have run, so read the session state before you repeat a mutation.
Connected, but history unavailableThe relay works and the history read failed. Use the reload action in the timeline.

Timeline events reach only interested phones

The lease deadline stops a session’s updates, even when a phone vanishes
The lease deadline stops a session’s updates, even when a phone vanishesA phone renews its interest lease every 20 seconds, and each renewal keeps the lease alive for 60 seconds more. The relay stores the lease and sends the desktop its deadline, so both sides know when it ends. If the phone disappears without closing its socket, updates sent in the meantime are lost, and at the deadline the desktop and the relay both stop without exchanging a message. A returning phone takes a new lease and loads a snapshot.
Desktopsession X
Relaylease per phone
Phoneshows session X
+60 s
+60 s
offline, socket still open
renews every 20 s
deadline to the desktop
lost
session X updates
deadline passes: both sides stop, no message
not serialized
new lease + snapshot
0 s
20 s
40 s
60 s
80 s
100 s
120 s
  • Interest leasechat:timeline-interest { appSessionId, active }

    Sent by the phone for the session on its screen and renewed every 20 seconds. The relay stores it per phone and never forwards it. It carries no timeline content.

  • Lease endTIMELINE_INTEREST_TTL = 60 s

    A lease ends 60 seconds after its last renewal, or at once when the phone sends active: false, opens another session, or its socket closes or is replaced. Queued updates for that phone go with it. The relay drops a silent socket only after 180 seconds, so the lease ends first.

  • Desktop deadlinechat:timeline-interest-updated { expiresAt }

    On every lease change the relay tells the desktop per session whether a lease exists and when it ends, never which phone holds it. The desktop keeps that deadline itself and stops serializing the session’s timeline once it passes.

  • Relay filterauthorizationGeneration

    Issued when a session goes from no lease to one. Every frame must carry the current value, and the relay strips it and sends the frame only to phones whose lease is still live at the final send. Nothing is kept for the others, so a returning phone loads a snapshot.

The lease is keyed by user, phone route, target desktop, and base session. appSessionId is trimmed, capped at 1,024 bytes, and stripped of a trailing skills-agent alias. Updates to the desktop also carry a revision and the relayInstanceId, so the desktop ignores stale ones and clears its state when they come from another instance.

Outbound timeline events wait in a per-connection coalescing buffer with one pending overlay per timeline key, a 512 KiB standard lane with an oversized lane above it, and a 250 millisecond flush. A saturated mailbox retires the connection generation, so the phone reconnects and reconciles instead of receiving a partial stream. The desktop mirrors this with 4 MiB of pending bytes, 64 regular events, and 64 coalesced timeline keys per connection.

Push registration and alerts

Both phones register a push token with PUT api/devices/push-token while signed in. The server sends exactly two kinds of push: the silent desktop_online data message when your desktop registers, which both apps use to restore their connection before you open them, and the visible alert written by the agent’s notification tool, which travels desktop, device link, server, then APNs or FCM.

Why the relay runs as one process

A new relay process remembers nothing, so every device starts over
A new relay process remembers nothing, so every device starts overRoutes, pending request records, interest leases, reconnect presence, and resume sessions exist only in the relay process’s memory. A deploy starts a new process with empty memory and drains the old one, which clears all of it without answering open requests, closes every device socket, and refuses registrations from then on. Devices reconnect to the new process, where their resume tokens are unknown, so each registers again and phones send their leases again. PostgreSQL survives the switch.
Old process
New process
Desktop
Phone
routing state in memory
refuses registrations
memory cleared, sockets closed
empty memory
resume token unknown
registers, no leases yet
open requests never answered
registers, sends its lease
new process starts
drain
devices reconnect
not to scale
PostgreSQL survives the switch with device rows, their status, push tokens, and presence history
  • Process memoryDeviceConnectionManager

    Holds every connection route, pending request record, reconnect presence, and interest lease. RelaySessionStore holds the resume sessions. A second process would see none of it, which is why the relay runs as one.

  • DrainrelayDraining

    One-way. From its start the process refuses registrations. It waits at most 60 seconds for routing work in progress, then clears its memory without answering open requests and closes every device socket.

  • Instance IDrelayInstanceId

    A new UUID per process, sent with every registration answer. Phones ignore presence events from another instance, and the desktop clears its interest state when updates come from a different one.

With two processes behind a load balancer, a phone whose socket landed on the process without its desktop’s socket would see that desktop as offline.

Production therefore has exactly one routing authority per region. A blue-green deploy starts the new process, drains the old one, and then routes traffic to the new one. Per connection the relay caps frames at 32 MiB, rate-limits with a token bucket of 300 burst and 150 per second, pings every 30 seconds, and drops a client after 180 seconds of silence.