Realtime
Replay what a client missed
Retain messages on a channel so a reconnecting tab catches up from where it left off.
History lets a channel keep the messages published to it so a client can read them back — on first subscribe, or after a reconnect that made it miss something. It is off by default and turned on per namespace.
Turn history on
History is a namespace setting, so it applies to every channel under that namespace. It has three modes:
| Mode | What is kept |
|---|---|
none | Nothing. This is the default. |
last-message | Only the most recent message on each channel. |
window | Every message published within a retention window. |
With window you also set historyWindowSeconds — the retention window in seconds. It is required in that mode, must be positive, and may not exceed 2592000 seconds (30 days). Messages older than the window stop being readable.
Namespaces are configured from the platform, not through the public routes — over MCP, realtime_namespace_update.
What publishing returns
When the namespace retains history, a publish is acknowledged with two extra fields:
{ "channel": "chat:room-1", "published": true, "offset": "1754130000123-0", "epoch": "b8d2…" }offset is the message’s position on the channel and epoch identifies the current run of that channel’s history. Together they form a cursor. When the namespace keeps no history, both fields are absent.
Messages published directly by a browser client are ephemeral: they are fanned out to subscribers but never retained, so they carry no
offsetorepochand cannot be replayed.
Read history
Your backend reads history on the history route listed in what your API key reaches. With the server SDK:
const page = await realtime.messages.history('chat:room-1', { lastN: 50 })Over MCP, realtime_history_get.
Ask for either a window or a cursor, never both, and never neither:
| Ask | Parameters | Returns |
|---|---|---|
| Window | last_n, an integer 1–1000 | The last N entries |
| Window | last_ms, a positive integer | Entries newer than now minus that many milliseconds |
| Cursor | offset and epoch, both required together | Entries strictly after that offset |
Sending only one half of a cursor is rejected. The response looks like this:
{
"channel": "chat:room-1",
"epoch": "b8d2…",
"recovered": true,
"entries": [
{ "id": "0f0a…", "data": { "text": "hi" }, "ts": 1754130000123, "offset": "1754130000123-0" }
]
}epochis the channel’s current epoch, ornullwhen the channel has no history yet.entriesare in publish order, oldest first.- Each entry carries the message
id, the publish timestamptsin milliseconds, and itsoffset. - An entry normally carries its payload inline as
data. Payloads larger than 262144 bytes are stored separately and the entry carries arefinstead —bucket_key,sizeand an optionalcontent_type— with nodatafield.
Reading a channel whose namespace is not registered returns 404. When the history backend cannot be reached the read returns 503; retry it.
Recovery on reconnect
The browser client tracks the offset and epoch of the last message it received on each channel. When the connection drops and comes back, it resubscribes with that cursor instead of the original history request, so the client gets exactly what it missed.
The subscribe acknowledgement tells you whether that worked:
client.subscribe('chat:room-1', onMessage, {
history: { lastN: 50 },
onSubscribed: ({ recovered }) => {
if (recovered === false) reloadFromYourBackend()
},
})onSubscribed fires on the initial subscribe and again after every reconnect.
What a refused recovery means
recovered: false — on the subscribe acknowledgement or on a cursor read — means the gap cannot be filled. It happens for two reasons:
- The channel’s epoch no longer matches the one in your cursor. The channel’s history has started a new run and the old offsets mean nothing in it.
- The messages between your cursor and the current head have aged out of the retention window, so part of the gap is gone for good.
In both cases no entries are returned. Treat it as a resync signal: discard whatever local state you built from the stream and re-read the current state from your own source of truth — your database, your API — then carry on with live messages.
Do not try a second recovery with the same cursor. It will be refused again.
Delivery and ordering
- Messages on one channel are retained and replayed in publish order. Offsets on a channel only increase.
- Delivery is at least once within the retention window. A reconnect, a replay that overlaps live delivery, or a repeated history read can hand your client the same message more than once.
- Deduplicate by message id. Every message carries an
idthat stays the same across live delivery and every replay. Keep the ids you have already processed for as long as your recovery window and drop repeats. - Nothing is guaranteed outside the retention window. Once a message ages out, neither a cursor read nor a window read will return it.
Keep history for longer
A namespace can also be given an archive setting: off, or a number of days from 1 to 3650. Archiving requires history to be on, and it keeps entries that have already aged out of the live retention window readable for the configured number of days. Entries older than that budget are deleted.
Archived history can also be exported as a file for offline use. Both the archive setting and exports are handled from the platform — over MCP, realtime_archive_get to read it, realtime_archive_export to start an export and realtime_archive_export_status to poll it.
Next steps
- Connect a browser tab: how the client tracks the cursor and resubscribes for you.
- Call Realtime from your backend: the
historyquery shapes and what an entry carries. - Show who is on a channel: the other thing a namespace can keep for you.
- Look up a limit or an error: retention ceilings,
last_nbounds and payload sizes.