Realtime channels
Deliver typed application updates to connected clients without turning events into application state.
A realtime channel sends live application updates to clients that are currently connected. An order-status change, a new announcement, or a dashboard invalidation are good channel events.
The channel is not your database. Load the current state from an ordinary route, then use channel events to update or invalidate that state. This distinction makes refresh and reconnect behavior predictable: the route restores the truth, while the channel keeps the page current.
Pre-setup
Use Redis in local Docker for replay, fanout, and presence. Start from an existing RELKIT app and a running Docker daemon.
Install Redis and Docker support
From this guide’s application/package root, install only its selected requirements:
bun add @relkit/docker@0.7.12 @relkit/redis@0.8.1Keep these owner-specific versions and the lockfile together. Runtime SDKs belong in dependencies; build/type tooling belongs in devDependencies. Installing an adapter does not supply its optional SDK peers or enable the feature’s configuration.
Configure the default realtime profile
/** Channels use Docker Redis by default during local development. */
import { defineApp, defineEnv } from "@relkit/app/config";
import { docker } from "@relkit/docker";
import { redisRealtime } from "@relkit/redis";
export default defineApp({
env: defineEnv({}),
realtime: docker(redisRealtime()),
defaults: { realtime: "default" },
});Channels without profile use default. Docker supplies Redis’s connection values, so there
are no additional application environment fields. Agent checkpoints need the separate
agent-state configuration.
Check and start after declaring a channel
Check Docker before continuing. After adding the resource and its service registration, run from the app root:
docker version
bun run check
bun run typecheck
bun run devcheck validates the graph; it starts no containers. dev starts only Docker recipes used by
that graph, waits for readiness, and binds their generated connection values. An unused profile
alone does not start a container. Keep the server running and use bun run relkit local status
in another terminal to inspect the services. Local Docker
covers detached startup and stopping services while retaining their volumes.
Realtime channels or application events?
RELKIT has two event-shaped features with different jobs:
| Use | Choose | Why |
|---|---|---|
| Notify browsers that are viewing a resource | Realtime channel | It supports scoped subscriptions, replay, and presence. |
| Run backend work after something happens | Application event | It delivers work to server-side consumers. |
| Return a sequence to the caller of one request | Typed stream | Its lifetime belongs to that request. |
A function may publish both an application event and a channel update when both outcomes are needed. Neither mechanism replaces the other.
Use a job watch for one accepted durable run. Realtime channels remain application notifications and do not become a substitute for task output, run status, or provider-backed recovery.
The Realtime API
| API | What you use it for |
|---|---|
defineChannel() |
Declare partition parameters, event payloads, browser access, replay, and presence. |
channel.trigger() |
Append one validated event from backend code. |
channel.getPresence() |
Read declared presence without joining the channel or changing its leases. |
useChannel() |
Observe one typed channel partition from React. |
useRealtime() |
Manage subscriptions directly when a hook per channel is not convenient. |
Declare a channel
This public announcements channel has one partition because its parameter schema is empty. It retains recent events for brief reconnects.
/** Declares public posted-announcement events with five-minute replay retention and a thousand-event limit. */
import { defineChannel } from "@relkit/app/realtime";
import { z } from "@relkit/app/schema";
const announcements = defineChannel({
id: "announcements.feed",
params: z.object({}),
events: { posted: z.object({ message: z.string(), postedAt: z.string() }) },
client: { public: true },
replay: { retentionMs: 300_000, maxEvents: 1_000 },
});
export default announcements;Subscribe from the frontend
After frontend setup and pulling the commerce contract, render
this component under the provider. It subscribes to this declaration’s announcements.feed channel:
"use client";
/** Combines the commerce announcement query with channel invalidation and reconnect recovery. */
import { useChannel, useRoute } from "@relkit/client/react";
/** Restores authoritative messages on publication, catch-up, and replay gaps.
* @returns The current messages with query failure and channel status feedback. */
export function Announcements() {
const messages = useRoute("GET /announcements", { input: {} });
const channel = useChannel("announcements.feed", {
params: {},
on: {
posted: () => {
void messages.refetch();
},
},
onCaughtUp: async () => {
await messages.refetch();
},
onGap: async () => {
await messages.refetch();
},
});
if (messages.isError) return <p role="alert">{messages.error.message}</p>;
return (
<section>
<p role="status">Channel: {channel.status}</p>
{messages.isPending && <p>Loading messages…</p>}
<ul>
{messages.data?.messages.map((message, index) => (
<li key={index}>{message}</li>
))}
</ul>
</section>
);
}params: {} selects the channel’s empty partition schema. posted is a declared event, so its
callback is checked against the event map. The route query restores the authoritative messages
after updates, catch-up, or replay gaps. Backend trigger() publishes; the browser subscribes.
Reference: useChannel, useRealtime.
Continue with subscription and recovery.
The declaration controls both publication and generated client types:
| Option | Meaning |
|---|---|
id |
Stable channel name used by backend and generated clients. |
params |
Schema that identifies a partition, such as one orderId. |
events |
Map of event names to their payload schemas. |
client |
Omit for backend-only use, set public: true, or provide an authorization guard. |
replay |
Optional retention window and maximum event count for reconnects. |
presence |
Optional connection count or protected member projection. |
profile |
Provider profile; otherwise the application default is used. |
An empty events map is valid only for a presence-only channel. Event binding is never an access-control boundary; browser access always comes from client.
This declaration generates the browser selector announcements.feed. The
frontend subscriber example shows its typed
posted callback, authoritative route query, and reconnect recovery.
Publish an update
Call trigger() from backend code after the application change succeeds:
/** Inserts a validated announcement, removes older demo rows and publishes its posted event before acknowledgement. */
import { defineFunction } from "@relkit/app/functions";
import { z } from "@relkit/app/schema";
import announcements from "../channels/announcements.channel.js";
const postAnnouncement = defineFunction({
input: z.object({ message: z.string().min(1) }),
output: z.object({ accepted: z.literal(true) }),
handler: async ({ message }, context) => {
await context.database.announcements.insert({
data: { message, createdAt: new Date().toISOString() },
});
const stale = await context.database.announcements.findMany({
orderBy: { field: "id", direction: "desc" },
limit: 1_000,
offset: 50,
});
for (const { id } of stale) {
await context.database.announcements.delete({ where: { id } });
}
await announcements.trigger({}, "posted", { message, postedAt: new Date().toISOString() });
return { accepted: true as const };
},
});
export default postAnnouncement;RELKIT validates the partition parameters and payload before appending the event. The returned receipt contains the event ID, checkpoint, provider profile and epoch, and whether an idempotent request was a duplicate.
accepted: true means the provider stored the append and its receipt atomically. It does not mean every subscriber received the event. A subscriber can disconnect immediately after the append and recover according to the channel’s replay policy.
For a retryable publish, pass an idempotencyKey created by RELKIT. Reusing the same UUIDv7 key with the same channel, partition, event, and payload during the 24-hour receipt window returns the original receipt. Reusing it for different content fails. Without a key, retrying after a lost response may append the event twice; RELKIT never retries it automatically.
If a database update and publication must succeed together, write an outbox entry in the same database transaction and publish from the outbox. Calling trigger() after a database write does not make the two operations atomic.
Run the backend example offline
The snippets above belong to the repository’s examples/commerce app. From the repository
root, with dependencies installed, run its focused HTTP test:
bun test examples/commerce/tests/http.test.tsThe test creates an application with an isolated local realtime provider, posts announcements through the public HTTP route, and reads them back. Expect two passing tests; no Docker or frontend is needed for this check. A passing test proves the function can append its channel event, but does not prove browser delivery or reconnect behavior. The React guide connects a browser after this backend path works, and Providers explains the local Redis setup for a live server.
Choose the next step
Access and revocation explains internal, public, and protected channels.
React channels connects generated channel types to application state.
Replay and recovery defines mount, reconnect, refresh, and gap behavior.
Presence adds connection or member awareness.