inngest-realtime
Use when streaming durable workflow updates to a UI in real time — live order status pages that animate as steps complete, AI agent token streaming from a function to the browser, log tailing for long-running jobs, or human-in-the-loop approval flows that publish a prompt and wait for a user reply.
By inngest · 556 installs
npx skills add inngest/inngest-skills --skill inngest-realtime
Source repository · Upstream listing
Inngest Realtime
Stream updates from durable Inngest functions to live UIs. Use channels and topics to broadcast progress, render workflow execution as it happens, or build bi directional human in the loop flows.
These skills are focused on TypeScript. For Python or Go, refer to the [Inngest documentation](https://www.inngest.com/llms.txt) for language specific guidance. Core concepts apply across all languages.
⚠ CRITICAL: v3 vs v4 package selection
Realtime in Inngest v4 lives at the SDK subpath inngest/realtime . The standalone @inngest/realtime npm package is a v3 era package and is NOT compatible with inngest@4.x . If your project is on v4 (the npm default), do not install @inngest/realtime . Use the imports below.
Symptoms of using the wrong package on v4: TypeError: Cls is not a constructor on every PUT /api/inngest , 401 on subscription tokens, type incompatibility on new Inngest({ middleware: [...] }) . Verify your package.json shows "inngest": "^4.x" before reading further.
Prerequisites
Inngest v4 SDK installed ( npm install inngest ) — see the inngest setup skill
INNGEST DEV=1 set in .env.local for local development (without it, the SDK demands cloud signing keys and 401s on token requests)
Local Inngest dev server running ( npx inngest cli@latest dev )
Optional: zod for schema validation on topics
When to use Realtime
Problem shape Pattern
Order status page animates as durable workflow steps complete Per run channel, publish per step, client subscribes
AI agent streams tokens to a chat UI Per conversation channel, publish chunks, stream to browser
Log tail for a long running job Single channel, log topic, append to UI
Human in the loop approval Channel + waitForEvent, publish prompt, wait for response
Admin dashboard with live order list Global admin channel, fan out from each function
Architecture
Three pieces:
1. Channel definition — a typed contract for what gets published. Lives in shared module so both server and client can reference the same channel name.
2. Publishing — call step.realtime.publish between steps to wrap a durable publish, or inngest.realtime.publish inside step.run because you're already inside a memoized step. See "Which publish method to use" below.
3. Subscribing — server action mints a subscription token; React client uses the useRealtime hook (or the lower level subscribe() API for non React consumers).
Step 1: Define a channel
Channels are pure data — no class hierarchy, no zod runtime required (but recommended for type safety). Define them once and import where needed.
Two channel name shapes:
name: 'admin' — static channel, accessed as adminChannel.order (topic ref)
name: (id) = 'channel:${id}' — parametric, accessed as orderChannel(id).step (call the channel def with the id, then access topic)
Step 2: Publish from inside a function
Inngest v4 ships realtime support natively — no middleware required. But where you call publish matters: it determines whether the publish is durable, and it's the most common place to get realtime wrong.
Which publish method to use
Where you are Use this Why
Outside a step (top level handler code, between step.run calls) step.realtime.publish(id, topicRef, data) Wraps the publish in its own step so it's durable, deduplicated by id , and retry safe.
Inside a step (inside the callback passed to step.run ) inngest.realtime.publish(topicRef, data) You're already inside a memoized step. step.realtime.publish would create a step inside a step. The bare client publish is the right call here.
Outside a function (one off route, script, etc.) inngest.realtime.publish(topicRef, data) Allowed, but not retry safe — your client receiver must handle duplicates.
The 90% rule: if you're writing handler code and you reach for publish , use step.realtime.publish . If you're writing code inside a step.run block and you reach for publish , use inngest.realtime.publish .
Example: both patterns in one function
Why no middleware: Earlier versions used @inngest/realtime 's realtimeMiddleware() to inject a publish arg into the handler. v4 puts it on step.realtime and inngest.realtime directly.
Step 3: Mint a subscription token (server action)
In Next.js App Router, use a Server Action to securely mint a short lived token for the React hook in Step 4. Without a token, clients can't subscribe.
getClientSubscriptionToken from inngest/react returns a token shape that the useRealtime hook in Step 4 consumes directly. No ChannelInstance stripping needed — that gotcha only applies to the lower level getSubscriptionToken + manual subscribe() path (see "Pattern: Manual subscribe" below).
Step 4: Subscribe with the useRealtime hook
The recommended consumer for React/Next.js is the useRealtime hook from inngest/react . It handles the subscription lifecycle, reconnect, type narrowing per topic, and cleanup.
Useful options on the hook:
Option Default Use it when
enabled true Delay the subscription until you have an ID (e.g., enabled: !!runId ).
bufferInterval 0 Batch updates from a fast stream so React doesn't re render per message.
pauseOnHidden false Pause the stream when the tab isn't visible (saves bandwidth).
autoCloseOnTerminal true Disconnect when the run completes — turn off to keep the stream open for fan out channels.
historyLimit unbounded Cap how many messages are retained in messages.all .
The hook returns messages.byTopic (latest per topic), messages.all (full history), messages.last (most recent), and messages.delta (new since last render).
Pattern: Manual subscribe (non React or custom transport)
The useRealtime hook covers the React case. If you're not using React, or you need a custom subscription lifecycle (server side streaming, background workers, custom protocols), use the lower level subscribe() API directly.
Server action: mint a token with the lower level helper
Manual client subscription
SSE streaming from a route handler
Subscribe inside a Next.js API route and pipe the stream to the client via SSE:
Client consumes via fetch().getReader() rather than the subscribe() callback. Use this when you want the SSE behavior or when the client side subscribe() API doesn't fit your component lifecycle.
Pattern: Human in the loop
Combine step.realtime.publish with step.waitForEvent :
The confirmationId links the published prompt to the matching reply, so the workflow knows which response to act on.
Common pitfalls
Don't use @inngest/realtime on v4
The standalone @inngest/realtime package is for Inngest v3 only. On v4, all realtime APIs are in the SDK subpath inngest/realtime . Mixing them produces:
TypeError: Cls is not a constructor on PUT /api/inngest (v3 middleware class signature mismatch)
401 Unauthorized on subscription tokens
TypeScript errors casting middleware
Verify with: grep '"inngest"' package.json — if it's ^4.x , use inngest/realtime . Period.
Don't return ChannelInstance from a Next.js server action (manual subscribe path only)
getSubscriptionToken returns { channel: ChannelInstance, ... } where ChannelInstance has zod schema methods (a class). Next.js refuses to serialize classes across the server action → client component boundary. Strip to primitives before returning. See "Pattern: Manual subscribe" above.
This gotcha does not apply when you use getClientSubscriptionToken from inngest/react (Step 3 — the recommended path). That helper returns a serialization safe shape directly.
INNGEST DEV=1 is required for local dev
Without it, the SDK assumes cloud mode and demands INNGEST SIGNING KEY + INNGEST EVENT KEY . All realtime operations 401 / 500. Add to .env.local . Hard restart the dev server (Next.js does not hot reload .env.local changes).
Channel topic schemas validate on publish, not on consume
If your published payload doesn't match the zod schema, the publish fails server side. Subscriber receives nothing. Catch publish errors during step execution, or run with validate: false in subscribe() if you have a reason to skip schema validation client side.
Reference
v4 entry points:
import { channel } from 'inngest/realtime' — channel definitions
import { useRealtime, getClientSubscriptionToken } from 'inngest/react' — React hook + matching token helper (Step 3 + Step 4)
import { getSubscriptionToken, subscribe } from 'inngest/realtime' — lower level helpers for non React or custom transport
Publish methods:
Outside a step: step.realtime.publish(id, topicRef, data) — wraps in a durable step
Inside step.run : inngest.realtime.publish(topicRef, data) — already inside a memoized step, no wrapping needed
Outside a function: inngest.realtime.publish(topicRef, data) — allowed but not retry safe
Subscribe overloads: subscribe(token) returns a stream; subscribe(token, callback) invokes callback per message
Next.js Server Action gotcha (manual path only): strip ChannelInstance → return { channel: string, topics, key, apiBaseUrl } . Not needed with getClientSubscriptionToken .