Replay and recovery
Keep a live view correct across mount, reconnect, refresh, and expired history.
Recover a disconnected client by combining current application state with channel updates.
Realtime events describe changes; they do not contain a complete application snapshot. Reliable recovery therefore combines an authoritative route query with a gap-free channel handoff.
What happens during each lifecycle
| Situation | RELKIT behavior | Application behavior |
|---|---|---|
| Fresh mount | Creates a live tail fence and reports caughtUp; it does not replay all retained history. |
Load authoritative state from a route, then use onCaughtUp to check or invalidate it. |
| Brief reconnect with replay | Reauthorizes and consumes records after the last applied checkpoint. | Continue applying events. |
| Brief reconnect without replay | Reports a gap and establishes a new live fence. | Reload authoritative state in onGap. |
| Full page refresh | Starts from a new live fence; raw callback cursors are not persisted by default. | Reload authoritative state. |
| Invalid or expired checkpoint | Reports a typed gap and refuses the checkpoint. | Replace derived state from its authoritative source. |
A cursor without the state it represents is not recovery. RELKIT does not persist component state merely because it has persisted or observed a channel position.
Implement gap recovery
Use onGap to refetch the route query or load another authoritative snapshot. The callback may be asynchronous, and caughtUp remains false until it resolves. This prevents the UI from presenting partially restored state as current.
Common gap reasons include expired retention, a checkpoint from another identity or session, a policy change, a provider reset, or a future or foreign checkpoint. Do not branch into separate repair algorithms unless your product needs different user messaging; refetching the authoritative view is usually enough.
Use onCaughtUp for the final revision check after the live tail is attached. This closes the window in which application data may change between the initial route response and subscription establishment.
Decide what to retain
Add replay when clients benefit from applying a short sequence of missed events after a transient disconnect. Set both:
| Option | Effect |
|---|---|
retentionMs |
Maximum age of replayable events. |
maxEvents |
Maximum retained event count for a partition. |
The order-updates channel keeps a bounded five-minute replay window:
/** Declares per-order status events with a native auth-context check, bounded replay and count presence. */
import { defineChannel } from "@relkit/app/realtime";
import { z } from "@relkit/app/schema";
const orderUpdates = defineChannel({
id: "orders.updates",
params: z.object({ orderId: z.string().min(1) }),
events: {
"status.changed": z.object({
orderId: z.string(),
status: z.string(),
totalCents: z.number().int().nonnegative(),
}),
},
client: {
authorize: async (_params, context) =>
typeof context === "object" && context !== null && "auth" in context,
},
replay: { retentionMs: 300_000, maxEvents: 10_000 },
presence: "count",
});
export default orderUpdates;
Retention is bounded by provider and application-wide storage limits. Reaching a limit does not silently evict unrelated unexpired work; the append fails with a typed overload response.
Replay should be an optimization, not the only way to rebuild a page. Keep an authoritative query even when the retention window is generous.
Delivery guarantees
The journal is the source of truth. Notifications only wake readers. Providers read after the current checkpoint, register their wait, read again, then wait with a bounded polling fallback. An event appended during replay-to-live attachment is therefore delivered from replay or the next live read rather than falling into a handoff gap.
A provider epoch change signals that retained state may have been reset. RELKIT reports the gap instead of claiming that an absent event or receipt never existed.