Realtime TypeScript SDK v4
Inngest Realtime lets your functions push updates directly to the browser as work happens. No polling. No WebSocket boilerplate. No separate pub/sub service.
You publish messages from inside your functions. Clients subscribe and receive them instantly. Everything is typed end to end.
Looking for the older @inngest/realtime package docs? See the archived
v3 docs here.
Four building blocks
Realtime has four concepts. Everything else builds on these.
Channels scope messages to a context. A channel might represent a user session, a document being processed, or a batch job. You define channels with a name or a function that generates a name from parameters like a user ID or document ID.
Topics organize messages within a channel. A channel for document processing
might have topics for status, review, error, and completed. Each topic
carries its own typed payload schema.
Publishing sends a message to a topic within a channel. You publish with
inngest.realtime.publish() for transient updates or
step.realtime.publish() when the message must not duplicate on retry.
Tokens authorize a client subscription. You mint them on the server, scoped to a single channel and an explicit list of topics.
Channel: document-processing:{documentId}
├── Topic: status → { progress: 50, message: "Analyzing..." }
├── Topic: review → { items: [...], requiresAction: true }
├── Topic: error → { error: "...", recoverable: true }
└── Topic: completed → { confidence: 0.95 }
On the client, you subscribe to a channel and its topics. Messages arrive as they're published. You render them however you want.
Define a channel
Channels are defined once and shared between your function code and your client
code. Each topic needs a schema, which can be any
Standard Schema validator
such as Zod, or staticSchema<T>() when you want types without runtime
validation.
inngest/channels.tsimport { realtime, staticSchema } from "inngest";
import { z } from "zod";
export const pipelineChannel = realtime.channel({
// The channel name is generated at runtime from the params you pass in.
// `contentId` is your own identifier, not an Inngest run ID.
name: ({ contentId }: { contentId: string }) => `pipeline:${contentId}`,
topics: {
// Zod schemas are validated at publish time.
status: {
schema: z.object({ message: z.string(), progress: z.number() }),
},
// `staticSchema` gives you types only, with zero runtime cost.
tokens: {
schema: staticSchema<{ token: string }>(),
},
result: {
schema: staticSchema<{ output: string; model: string }>(),
},
},
});
TypeScript enforces these types when you publish and when you subscribe.
realtime and staticSchema are imported from "inngest". The same helpers
are also available from the "inngest/realtime" subpath.
Publish from a function
There are two ways to publish. inngest.realtime.publish() is non-durable and
fires immediately. step.realtime.publish() is a durable step that is memoized,
so it will not re-fire if the function retries.
inngest/functions/summarize.tsimport { inngest } from "../client";
import { pipelineChannel } from "../channels";
export default inngest.createFunction(
{ id: "summarize-content", triggers: [{ event: "app/content.submitted" }] },
async ({ event, step }) => {
// Create a channel instance for this piece of content. The `contentId`
// comes from the event, and the client uses the same value to subscribe,
// which is what connects the two sides together.
const ch = pipelineChannel({ contentId: event.data.contentId });
// Durable publish (within a step). This caries the same benefit of step.run
// to handle retries without publishing duplicate messages
await step.realtime.publish('starting-status', ch.status, {
message: "Starting...",
progress: 0,
});
const result = await step.run("call-model", async () => {
const stream = await generateTextStream(event.data.content);
// Use inngest.realtime.publish for sending messages within steps
let text = ''
for await (const chunk of stream) {
await inngest.realtime.publish(ch.tokens, { token: chunk.delta });
text += chunk.delta;
}
return text;
});
}
);
Use inngest.realtime.publish() for high-frequency updates within steps like streaming
tokens or progress ticks. Use step.realtime.publish() for state
transitions that should not duplicate on retry.
inngest.realtime.publish() also works outside a function, in API routes,
webhooks, or any server-side code. Inside a function run it automatically
attaches the current run ID.
Mint a subscription token
Clients cannot subscribe directly. Your server mints a token scoped to one channel and an explicit list of topics, which is also where you check that the current user is allowed to see that data.
app/actions.ts"use server";
import { getClientSubscriptionToken } from "inngest/react";
import { inngest } from "@/inngest/client";
import { pipelineChannel } from "@/inngest/channels";
import { getSession } from "@/lib/session";
export async function fetchToken(contentId: string) {
// Authorize before minting: the token is a capability, so only hand one out
// once you know this user may read this channel.
const { userId } = await getSession();
await assertUserOwnsContent(userId, contentId);
return getClientSubscriptionToken(inngest, {
channel: pipelineChannel({ contentId }),
topics: ["status", "result"],
});
}
See Subscription tokens for authorization patterns, token refresh, and framework examples.
Subscribe from the browser
In React, use the useRealtime hook from inngest/react.
app/page.tsx"use client";
import { useRealtime } from "inngest/react";
import { pipelineChannel } from "@/inngest/channels";
import { fetchToken } from "./actions";
export default function Progress({ contentId }: { contentId: string }) {
const { messages, connectionStatus } = useRealtime({
// Pass the channel instance (not just a name) so message data stays typed.
channel: pipelineChannel({ contentId }),
topics: ["status", "result"] as const,
// A token factory runs on mount and on every reconnect, so the hook
// always has a fresh token.
token: () => fetchToken(contentId),
});
return (
<div>
<p>Connection: {connectionStatus}</p>
<p>{messages.byTopic.status?.data.message}</p>
<p>Progress: {messages.byTopic.status?.data.progress}%</p>
{messages.byTopic.result && (
<p>Result: {messages.byTopic.result.data.output}</p>
)}
</div>
);
}
The hook manages token refresh, reconnection, and buffering. Messages are typed per topic.
useRealtime accepts a number of options beyond the three shown here, including
enabled, bufferInterval, historyLimit, and reconnection controls. See the
React hooks guide
for the typical Next.js flow, and the
useRealtime reference
for every parameter and return value.
When to use each publish method
- Prefer
step.realtime.publish()for important state transitions and final results. - Use
inngest.realtime.publish()for high-frequency updates like tokens, logs, or progress ticks where replay on retry is acceptable, and for publishing from routes, webhooks, or other server-side code outside a function run.
Learn more
Stream AI responses to your UI
Walk through token-by-token LLM streaming with a typed channel and the
useRealtime hook.
Use React hooks in Next.js
Learn the v4 useRealtime flow with server-minted tokens and typed
messages.
Secure subscriptions with tokens
Scope tokens to channels and topics, authorize users, and handle refresh.
Explore patterns and examples
See single-run subscriptions, multi-channel fanout, and human-in-the-loop examples.
Open the v4 reference docs
Dive into channels, publishing, subscribing, and the complete hook API.
View archived v3 docs
Reference the deprecated @inngest/realtime concepts if you still maintain
a v3 app.