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.ts
import { 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.ts
import { 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