Durable token streaming
Stream LLM tokens to the browser while running generation as a durable function, so a dropped connection or a crash never loses the response.
Token streaming means the LLM server returns tokens one at a time as it generates them, so your agent produces a progressive "typing" effect instead of delivering everything at once. Users start reading a long output sooner, they can stop the agent midway if it's heading the wrong direction, and a response they watch arrive feels faster than one they wait on. Most implementations reach for WebSockets or SSE (Server-Sent Events) to push near-realtime updates from the agent to the app.
But both depend on one fragile persistent connection: if the socket drops, the run dies with it. Lost progress, half-finished tool calls. This pattern keeps token streaming alive through a dropped connection, using realtime connections and Inngest connect.
ยงArchitecture
Decouple the agent's execution from the transport that carries its tokens.
Start with a standard REST request. The browser POSTs to /api/chat and gets an event id back immediately.
The Inngest execution engine receives the chat/message.sent event and issues run requests to the worker over the connect websocket.
The worker runs the agent's tasks (model turns, tool calls) as step.run and publishes tokens as the model produces them.
The browser subscribes with short-lived subscription tokens and receives the live stream.
Because the Inngest engine tracks progress instead of the socket:
- If the worker dies, Inngest retries the function on the next healthy worker.
- Finished steps replay instantly, since Inngest memoizes each one as it completes.
- The interrupted turn restreams from
seq: 0; the UI resets just that bubble. - The browser can re-subscribe at any time; it's only a viewer.
๐ก The tradeoff: there's no server-side session store. The browser holds the transcript and POSTs all of it on every /api/chat call. That keeps the server stateless, so any worker instance can pick up any request, and it keeps this example small. The cost is that the browser is the only copy of history. A real app would persist history (e.g. keyed by sessionId) and send only the new message.ยงHow to Implement Durable Token Streaming?
The full working example is in the repo. Below are the pieces that matter.
The Worker
To cut latency, open a direct websocket connection from the worker to Inngest. Import and set up the inngest/connect package.
01import { connect } from "inngest/connect";02import { inngest } from "../inngest/client";03import { chatFn } from "./chat-function";0405// Establish an outbound persistent connection to Inngest.06const connection = await connect({07 apps: [{ client: inngest, functions: [chatFn] }],08 // Identifies this worker instance for horizontal scaling and rolling09 // deploys. Defaults to hostname if unset; in containers set this to the10 // container id.11 instanceId: process.env.INNGEST_INSTANCE_ID,12});1314console.log("Worker: connected", connection.state);1516// Block until the connect socket gracefully closes (SIGTERM/SIGINT), then exit.17await connection.closed;18console.log("Worker: shut down");19process.exit(0);Next, a channel to publish the stream, with two topics: status and tokens.
01import { channel, staticSchema } from "inngest/realtime";0203// Shared contract between the worker (publisher) and the browser (subscriber).04// Both sides import this file, so the topic shapes can never drift apart.0506// One `tokens` message is a small batch of streamed text for a single model07// turn. `seq` is a per-turn counter starting at 0 โ the UI groups by `turn`08// and orders by `seq`, and treats a re-appearing `seq: 0` as a replay (the09// turn's step was retried, so its buffer should be reset and re-built).10export type TokenMessage = { turn: number; seq: number; delta: string };1112// Lifecycle/status events, published durably (`step.realtime.publish`) so13// they survive worker restarts and are never duplicated or dropped.14export type StatusMessage =15 | { type: "run.started" }16 | { type: "tool.called"; turn: number; name: string; input: unknown }17 | { type: "tool.result"; turn: number; name: string; output: string }18 | { type: "turn.completed"; turn: number; text: string }19 | { type: "run.completed"; text: string }20 | { type: "run.failed"; error: string };2122// One channel per chat session, with two topics: high-frequency token deltas23// and low-frequency lifecycle status. Subscribers pick a session by calling24// `chatChannel(sessionId)`.25export const chatChannel = channel({26 name: (sessionId: string) => `chat:${sessionId}`,27 topics: {28 tokens: { schema: staticSchema<TokenMessage>() },29 status: { schema: staticSchema<StatusMessage>() },30 },31});3233// Client-owned chat history shape (see api/chat/route.ts) โ this app has no34// server-side session store, so the browser sends the full transcript on35// every request.36export type ChatMessage = { role: "user" | "assistant"; content: string };statuspublishes durably, throughstep.realtime.publish. The publish is a step, so it's memoized and a completed turn's status never gets re-sent.tokenspublishes non-durably, through a plaininngest.realtime.publishcall. Nothing is memoized, so when a step retries the client gets the text streamed again.
๐ก One caveat: publishing every single token wastes bandwidth. The worker batches deltas and flushes every 40ms instead; frequent enough to read as live typing, without a message per token.
01// Runs one model turn as a single durable step. Streaming to the browser is a02// side effect of that step (non-durable `inngest.realtime.publish` calls) โ03// if the step retries, it re-streams from `seq: 0` and the UI resets that04// turn's buffer (see Chat.tsx); once the step completes, it's memoized and05// never re-streams or re-calls the model again.06const result = await step.run(`llm-turn-${turn}`, async () => {07 let seq = 0;08 let buffer = "";09 let timer: ReturnType<typeof setTimeout> | null = null;10 const pending: Promise<unknown>[] = [];1112 const flush = () => {13 if (!buffer) return;14 const delta = buffer;15 buffer = "";16 timer = null;17 // Non-durable and non-blocking: this is a live UI nicety, not part of18 // the durable record. `turn.completed` (below, durable) is the19 // authoritative text the UI falls back to if any deltas are lost. Each20 // publish is caught individually โ a dropped batch is recoverable, but21 // an unhandled rejection is fatal under Node's default settings โ and22 // tracked in `pending` so the step doesn't settle with publishes still23 // in flight (a serverless runtime may freeze the instance as soon as24 // the handler returns, silently dropping the tail of the stream).25 pending.push(26 inngest.realtime.publish(ch.tokens, { turn, seq: seq++, delta }).catch(() => {}),27 );28 };2930 const stream = client.messages.stream({31 model,32 // Both experiment variants (Haiku 4.5 and Opus 4.8) stream and support33 // โฅ64K max output tokens; 16K leaves ample room for ~20 parallel tool34 // calls plus a long final answer in one turn while keeping worst-case35 // cost bounded alongside MAX_TURNS.36 max_tokens: 16384,37 system: SYSTEM_PROMPT,38 tools: toolDefinitions,39 messages,40 });4142 stream.on("text", (delta) => {43 buffer += delta;44 timer ??= setTimeout(flush, BATCH_MS);45 });4647 try {48 return await stream.finalMessage();49 } finally {50 // Always cancel a pending batch so no flush fires after the step51 // settles โ on failure, a stray timer would otherwise publish stale52 // tokens into the retry's fresh stream.53 if (timer) clearTimeout(timer);54 flush();55 // Never rejects โ every promise in `pending` is already caught.56 await Promise.all(pending);57 }58});The Browser
The Next.js app imports the same ./inngest/channel module the worker uses, then subscribes with the inngest/react package.
01import { getClientSubscriptionToken } from "inngest/react";02import { inngest } from "../../../inngest/client";03import { chatChannel } from "../../../inngest/channel";0405// Mints a short-lived subscription token scoped to one session's channel and06// its two topics. The browser fetches this right before subscribing with07// `useRealtime` (see components/Chat.tsx) โ the token is never embedded in08// page HTML or reused across sessions.09export async function POST(req: Request) {10 const { sessionId } = (await req.json()) as { sessionId: string };1112 if (!sessionId) {13 return Response.json({ error: "Expected { sessionId }" }, { status: 400 });14 }1516 const token = await getClientSubscriptionToken(inngest, {17 channel: chatChannel(sessionId),18 topics: ["tokens", "status"],19 });2021 return Response.json(token);22}The token is scoped to one session's channel and its two topics, and the browser mints a fresh one right before it subscribes. It never lands in the page HTML and never gets reused across sessions.
01import { useRealtime } from "inngest/react";02...030405export default function Chat() {06 // One id per browser tab/session, generated once. There's no server-side07 // session store โ the transcript below is the only place history lives.08 const [sessionId] = useState(() => crypto.randomUUID());09 const [transcript, setTranscript] = useState<TranscriptEntry[]>([]);10 const [input, setInput] = useState("");11 const [running, setRunning] = useState(false);12 // A failed run keeps whatever trace it accumulated too, so it isn't just13 // silently discarded โ it's shown alongside the error line.14 const [errorBanner, setErrorBanner] = useState<{ message: string; trace: TraceItem[] } | null>(null);15 const bottomRef = useRef<HTMLDivElement | null>(null);1617 const { messages, reset } = useRealtime({18 channel: chatChannel(sessionId),19 topics: ["tokens", "status"],20 token: async () => {21 const res = await fetch("/api/realtime-token", {22 method: "POST",23 headers: { "Content-Type": "application/json" },24 body: JSON.stringify({ sessionId }),25 });26 return res.json();27 },28 // Only subscribe while a run is actually in flight โ no point holding a29 // connection open (and no realtime token to mint) between sends.30 enabled: running,31 });3233 ...The client keeps no run state, which makes reconnecting cheap. It groups deltas by turn, orders them by seq, and reads a re-appearing seq: 0 as a replay: that turn's step retried, so the browser clears the bubble's buffer and rebuilds it. There's no cursor to persist and no gap to backfill. If deltas go missing entirely, the durable turn.completed still arrives with the authoritative text, so the worst case is a bubble that fills in all at once instead of typing.
Finally, the route that sends the event.
01// Stateless server: the browser owns the full transcript and sends it on02// every request (see components/Chat.tsx). Simplest possible design for an03// example โ a real app would likely persist history server-side instead.04export async function POST(req: Request) {05 const { sessionId, messages } = (await req.json()) as { sessionId: string; messages: ChatMessage[] };0607 if (!sessionId || !Array.isArray(messages)) {08 return Response.json({ error: "Expected { sessionId, messages }" }, { status: 400 });09 }1011 const { ids } = await inngest.send({12 name: "chat/message.sent",13 data: { sessionId, messages },14 });1516 return Response.json({ eventId: ids[0] });17}ยงSummary
Ask the agent for something long, like a two-page report on current weather conditions in Omaha, NE, and tokens start arriving right away instead of after the whole generation finishes.
On the Inngest dashboard, the model turns and tool-call steps are all tracked, ready to retry if one fails.

Clone the repo, swap in your own model and tools, then kill the worker mid-stream and watch the turn restream from seq: 0.