Skip to content
RELKIT
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

Reference and source