Publishing

There are two ways to publish realtime messages in the v4 SDK.

Prefer step.realtime.publish() whenever you can. It is durable and memoized, so it will not run again if the function retries. Reach for inngest.realtime.publish() only when you specifically need non-durable behavior, such as high-frequency token streaming or publishing from code outside a function.

MethodDurableUsable outside functionsStep IDBest for
inngest.realtime.publish()NoYes-High-frequency progress, and publishing from routes, webhooks, or server-side code
step.realtime.publish()YesNoRequiredState transitions, final results, deduped publishes

inngest.realtime.publish(topicRef, data)

Publishes from your Inngest client. Use this anywhere you already have the client available, such as API routes, webhooks, or other server-side code. Inside a function run, inngest.realtime.publish() also attaches the current run ID automatically.

  • Name
    topicRef
    Type
    TopicRef<TData>
    Required
    required
    Description

    A topic accessor from a channel instance.

  • Name
    data
    Type
    TData
    Required
    required
    Description

    The message payload. Must match the topic's schema.

Returns Promise<void>.

import { inngest } from "./client";
import { alertsChannel } from "./channels";

export async function POST(req: Request) {
  const body = await req.json();

  //
  // Non-durable by design. Prefer step.realtime.publish() when this work
  // lives inside a function and duplicates on retry would be a problem.
  await inngest.realtime.publish(alertsChannel.alert, {
    message: body.message,
    severity: body.severity,
  });

  return new Response("OK");
}

inngest.realtime.publish() works at the top level of your function handler and inside step.run() blocks. It is non-durable in both cases, so it fires again on retry.

Inside a function, this is the method to reach for when publishing at high frequency, such as streaming model output token by token:

inngest.createFunction(
  { id: "stream-tokens", triggers: [{ event: "app/generate" }] },
  async ({ event, step }) => {
    const ch = pipelineChannel({ contentId: event.data.contentId });

    await step.run("generate", async () => {
      const stream = await openai.responses.create({
        model: "gpt-5",
        input: [{ role: "user", content: event.data.prompt }],
        stream: true,
      });

      let full = "";
      for await (const chunk of stream) {
        if (chunk.type === "response.output_text.delta") {
          full += chunk.delta;

          //
          // Non-durable on purpose. This can run again on retry, which is
          // usually acceptable for token streams.
          await inngest.realtime.publish(ch.tokens, { token: chunk.delta });
        }
      }

      return full;
    });
  },
);

step.realtime.publish(id, topicRef, data)

A durable step that memoizes the publish. If the function retries past this step, the publish won't re-fire. The message appears in the function's execution graph. Best for important state transitions and final results.

  • Name
    id
    Type
    string
    Required
    required
    Description

    A unique step ID. Used for memoization and appears in function logs.

  • Name
    topicRef
    Type
    TopicRef<TData>
    Required
    required
    Description

    A topic accessor from a channel instance.

  • Name
    data
    Type
    TData
    Required
    required
    Description

    The message payload. Must match the topic's schema.

Returns Promise<TData>, the published data.

inngest.createFunction(
  { id: "process-upload", triggers: [{ event: "app/upload" }] },
  async ({ event, step }) => {
    const ch = uploadsChannel({ uploadId: event.data.uploadId });

    //
    // This status update is ephemeral, so non-durable publish is fine.
    await inngest.realtime.publish(ch.status, { message: "Processing..." });

    const result = await step.run("process", async () => {
      return processUpload(event.data);
    });

    //
    // Prefer the durable publish for important state that should not
    // duplicate if the function retries.
    await step.realtime.publish("publish-result", ch.result, {
      success: true,
      url: result.url,
    });
  },
);

Choosing a publish method

Prefer step.realtime.publish() by default when the publish happens inside a function and duplicates would be incorrect or noisy.

Use inngest.realtime.publish() when:

  • Streaming tokens, progress percentages, or log lines
  • The data is ephemeral and duplicates on retry are fine
  • You want minimum latency (no step overhead)
  • Publishing from outside a function (API routes, webhooks, cron jobs)

Use step.realtime.publish() when:

  • Publishing a final result or state transition
  • You need exactly-once delivery semantics (memoized)
  • The publish should appear in the function's execution graph

Type safety

Both methods validate data against the topic's schema at both compile time and runtime:

const ch = pipelineChannel({ contentId: "abc" });

// TypeScript error - missing required field
await inngest.realtime.publish(ch.status, { message: "ok" }); // ✓
await inngest.realtime.publish(ch.status, { wrong: "field" }); // ✗ compile error

// Runtime validation - throws if data doesn't match Zod schema
await inngest.realtime.publish(ch.status, someUntypedData); // validated at runtime