# 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

### When to recover

Run a catch-up:

- after a socket reconnects following a gap (see [connection](/realtime/connection/)),
- when the page becomes visible again (`visibilitychange`),
- when the browser fires `online`,
- when the host page sends `refresh` through the [frame protocol](/reference/frame-protocol/#refresh), for example after a purchase on the host page.

### 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`](/api/operations/list-conversations/),
- a thread: the latest page of [`GET /v1/conversations/{id}/messages`](/api/operations/list-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

1. Call [`GET /v1/conversations?changed_since=<seq>&limit=50`](/api/operations/list-conversations/) 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.

### Thread catch-up

1. Call [`GET /v1/conversations/{id}/changes?since=<seq>&limit=200`](/api/operations/message-changes/) 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 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`

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](/errors/) page lists the problem body.

### What to refetch besides the cursor

Some state takes no `seq`. Refetch it on every recovery:

| State | Call |
|---|---|
| Unread total | [`GET /v1/unread`](/api/operations/get-unread/) |
| Calls ringing for you | [`GET /v1/calls/ringing`](/api/operations/ringing-calls/) |
| A purchase still `pending` | [`GET /v1/purchases/{id}`](/api/operations/get-purchase/) |
| The open conversation | [`GET /v1/conversations/{id}`](/api/operations/get-conversation/) |

### 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}`](/api/operations/get-conversation/) (`Peer.online`, `Peer.last_seen_at`), then follow live `presence.changed` events. See [presence subscriptions](/realtime/presence/).

### 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](/realtime/ordering/) has the rules.

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

## Limits

- `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.

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

## Related topics

- [`GET /v1/conversations`](/api/operations/list-conversations/)
- [`GET /v1/conversations/{id}/changes`](/api/operations/message-changes/)
- [Event catalog](/reference/events/)
- [Errors](/errors/)
- [Connection](/realtime/connection/)
- [Ordering and limits](/realtime/ordering/)
- [Presence subscriptions](/realtime/presence/)