Skip to content

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.

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 refresh through the frame protocol, for example after a purchase on the host page.

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:

Take your first cursor from one of these loads. Never start from 0: a cursor of 0 is below the floor and answers 410.

  1. Call GET /v1/conversations?changed_since=<seq>&limit=50 with your held latest_seq. limit is at most 50. changed_since cannot be combined with cursor, view or q (422).
  2. Apply each row in items as you apply a conversation.updated event. Rows come oldest change first.
  3. Remove the conversations listed in removed. They are no longer in the inbox (no message left).
  4. While has_more is true, call again with changed_since=<next>.
  5. Keep the last latest_seq as your new cursor.
  1. Call GET /v1/conversations/{id}/changes?since=<seq>&limit=200 with the thread’s held latest_seq. limit is 1 to 200, default 100.
  2. Apply each message in items as you apply a message.updated event. Messages come by seq, 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.
  3. Remove the messages listed in hidden. The viewer deleted them for themselves.
  4. Apply participants: both users’ last_read_message_id and last_delivered_message_id now. The peer’s read pointer is null while either side has read_receipts off.
  5. While has_more is true, call again with since=<next>.
  6. Keep the last latest_seq as 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 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.

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.

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}

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.

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.

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 next for 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.
  • changed_since: limit at most 50.
  • /changes: limit 1 to 200, default 100.
  • A cursor has no time limit. It stays valid until a hard delete or a merge raises the floor.
  • seq has gaps. Never infer missing data from a gap.
  • 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.