Sync and recovery
The realtime socket does not replay events. A client that was offline, asleep or reconnecting has missed whatever was sent in between. Recovery brings it back to the server’s state over HTTP. You can do it two ways:
- Refetch: load the inbox’s first page and the open conversation again. This is the simplest path and always correct.
- Delta sync: keep a cursor and ask only for what changed since it. This is cheaper and keeps rows the user loaded by scrolling.
Both are supported. Use delta sync when you cache the inbox or threads locally.
How it works
Section titled “How it works”When to recover
Section titled “When to recover”Run a catch-up:
- after a socket reconnects following a gap (see connection),
- when the page becomes visible again (
visibilitychange), - when the browser fires
online, - when the host page sends
refreshthrough the frame protocol, for example after a purchase on the host page.
The cursor: seq and latest_seq
Section titled “The cursor: seq and latest_seq”Every change to a message takes the next seq of its conversation. Every change to an inbox row takes the next seq of its viewer. Message.seq and ConversationSummary.seq carry them, and so do the message.created, message.updated and conversation.updated events.
Lists return latest_seq, the cursor to hold:
- the inbox: the first page of
GET /v1/conversations, - a thread: the latest page of
GET /v1/conversations/{id}/messages.
Take your first cursor from one of these loads. Never start from 0: a cursor of 0 is below the floor and answers 410.
Inbox catch-up
Section titled “Inbox catch-up”- Call
GET /v1/conversations?changed_since=<seq>&limit=50with your heldlatest_seq.limitis at most 50.changed_sincecannot be combined withcursor,vieworq(422). - Apply each row in
itemsas you apply aconversation.updatedevent. Rows come oldest change first. - Remove the conversations listed in
removed. They are no longer in the inbox (no message left). - While
has_moreis true, call again withchanged_since=<next>. - Keep the last
latest_seqas your new cursor.
Thread catch-up
Section titled “Thread catch-up”- Call
GET /v1/conversations/{id}/changes?since=<seq>&limit=200with the thread’s heldlatest_seq.limitis 1 to 200, default 100. - Apply each message in
itemsas you apply amessage.updatedevent. Messages come byseq, rendered as the viewer sees them:- deleted ones as tombstones,
- redacted ones without content,
- the other user’s held or rejected ones as status only, with no content,
- never the other user’s undelivered ones.
- Remove the messages listed in
hidden. The viewer deleted them for themselves. - Apply
participants: both users’last_read_message_idandlast_delivered_message_idnow. The peer’s read pointer is null while either side hasread_receiptsoff. - While
has_moreis true, call again withsince=<next>. - Keep the last
latest_seqas your new cursor.
A message can come back with nothing you can see changed (opening a message rewrites its row). Apply rows so that the same row twice does no harm.
A slower commit is never skipped
Section titled “A slower commit is never skipped”A change that has taken a seq but has not committed yet does not make a catch-up wait. The call returns without it, and your next call from next returns it once it commits. So a cursor never jumps over a change.
410 stale_cursor
Section titled “410 stale_cursor”Both catch-ups answer 410 gone with reason: stale_cursor when the cursor is below the floor or above latest_seq. The floor rises when:
- messages are deleted for good, by retention or by deleting a user,
- a guest merges into a member and that deletes a conversation of the guest (this raises the peer’s inbox floor).
On 410, reload: the inbox’s first page or the thread’s latest messages. Keep their new latest_seq and sync from it. The errors page lists the problem body.
What to refetch besides the cursor
Section titled “What to refetch besides the cursor”Some state takes no seq. Refetch it on every recovery:
| State | Call |
|---|---|
| Unread total | GET /v1/unread |
| Calls ringing for you | GET /v1/calls/ringing |
A purchase still pending |
GET /v1/purchases/{id} |
| The open conversation | GET /v1/conversations/{id} |
Presence after a gap
Section titled “Presence after a gap”A catch-up does not recover presence. Presence never takes a seq and the socket does not replay. Read current presence from a first-page inbox load or GET /v1/conversations/{id} (Peer.online, Peer.last_seen_at), then follow live presence.changed events. See presence subscriptions.
Order and duplicates
Section titled “Order and duplicates”A catch-up and a live event can bring the same row. Keep the copy with the higher seq and never replace a row or message with a lower one. Deduplicate events on id. Ordering and limits has the rules.
What the frame does
Section titled “What the frame does”The chat frame is one client of these routes. Its choices are recommendations, not server limits:
- It catches up the inbox only while its last first-page load is under 15 minutes old, and follows
nextfor at most 10 pages. Past either, or on any failure, it loads the first page. - It keeps the rows loaded by scrolling and its next-page cursor through an inbox catch-up.
- It catches up the open thread for at most 5 pages, and loads the latest page past that or on any failure.
- It catches up when the user comes back to the inbox more than 30 s after its last first-page load.
- It skips a catch-up message older than the oldest loaded one while earlier pages remain; the earlier page brings it.
- It keeps the last 5 threads in memory with their cursors, so reopening one shows it at once and catches up.
Limits
Section titled “Limits”changed_since:limitat most 50./changes:limit1 to 200, default 100.- A cursor has no time limit. It stays valid until a hard delete or a merge raises the floor.
seqhas gaps. Never infer missing data from a gap.
Where it ends
Section titled “Where it ends”- Settings changes, photos removed by retention and presence take no
seq. A catch-up does not return them. Refetch media links when they fail to load, and read presence as above. - Typing is ephemeral and is never recovered.
- Unread totals, ringing calls and pending purchases are not in any cursor. Refetch them.
- After a 410, a catch-up cannot bring back what was deleted. Reload and drop what you held.