Async Iterators for Streaming in the TypeScript SDK
Consume message deltas with for-await loops instead of manual event listeners.
Summary
The @anthropic-ai/sdk package streams responses from Claude as a sequence of events, and the modern way to consume them is a for await...of loop over the stream object returned by client.messages.stream(...).
Older SDK versions and many tutorials wire up streaming with .on('text', ...) style event listeners, borrowed from Node's EventEmitter pattern. That style still works, but it scatters state across callback closures and makes error handling awkward.
Async iterators fold naturally into normal async/await control flow. You write one linear loop, use try/catch for errors, and break out early exactly like you would with an array.
The stream object returned by .stream() is both an async iterable (for the granular event-by-event view) and a helper with finalMessage() (for the complete assembled response once the stream ends).
This page contrasts both styles and walks through a full example that streams a chat response and renders text deltas incrementally.
Recipe
Quick-reference recipe card, copy-paste ready.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const stream = client.messages.stream({
model: "claude-sonnet-5",
max_tokens: 1024,
messages: [{ role: "user", content: "Write a haiku about async iterators." }],
});
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
process.stdout.write(event.delta.text);
}
}
const message = await stream.finalMessage();
console.log("\n\nUsage:", message.usage);When to reach for this:
- You want to render tokens incrementally in a UI, CLI, or terminal as they arrive.
- You need the final assembled message (text, stop reason, usage) after the stream completes.
- You want type-safe handling of different content block types without manual event names.
- You're writing new streaming code and have no legacy reason to use event listeners.
Working Example
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
async function streamChatResponse(userMessage: string): Promise<void> {
const stream = client.messages.stream({
model: "claude-sonnet-5",
max_tokens: 1024,
messages: [{ role: "user", content: userMessage }],
});
let currentBlockType: string | null = null;
for await (const event of stream) {
switch (event.type) {
case "content_block_start":
currentBlockType = event.content_block.type;
if (currentBlockType === "text") {
process.stdout.write("Claude: ");
}
break;
case "content_block_delta":
if (event.delta.type === "text_delta") {
// Render text deltas incrementally as they arrive.
process.stdout.write(event.delta.text);
} else if (event.delta.type === "thinking_delta") {
// Extended thinking deltas arrive on their own block type.
process.stdout.write(event.delta.thinking);
}
break;
case "content_block_stop":
if (currentBlockType === "text") {
process.stdout.write("\n");
}
currentBlockType = null;
break;
case "message_stop":
// The stream has ended; finalMessage() below is now cheap.
break;
}
}
// After the loop, the complete message is available without re-reading events.
const finalMessage = await stream.finalMessage();
console.log("Stop reason:", finalMessage.stop_reason);
console.log("Input tokens:", finalMessage.usage.input_tokens);
console.log("Output tokens:", finalMessage.usage.output_tokens);
}
streamChatResponse("Explain async iterators in two sentences.").catch((error) => {
console.error("Streaming failed:", error);
});What this demonstrates:
- A single linear
for await...ofloop replaces multiple named event-listener callbacks. - Narrowing on
event.typeandevent.delta.typegives type-safe access totext,thinking, and other delta variants without casting. try/catch-style error handling (here,.catch()on the outer async function) covers the whole stream, not just individual events.stream.finalMessage()returns the complete assembledMessageobject after iteration finishes, with no extra bookkeeping.- Local variables like
currentBlockTypelive in ordinary loop scope instead of being smuggled through closures.
Deep Dive
How It Works
client.messages.stream(...)opens a server-sent-events connection to the Messages API and returns aMessageStreamobject immediately, before any events have arrived.MessageStreamimplementsSymbol.asyncIterator, sofor await (const event of stream)pulls events off the connection one at a time, in order, awaiting each one as it becomes available.- Each iteration yields one event of the raw stream protocol:
message_start,content_block_start,content_block_delta,content_block_stop,message_delta, ormessage_stop. - Because the loop is just
asynccontrol flow, areturn,break, or thrown error inside it propagates the way it would in any otherfor awaitloop, and the underlying connection is cleaned up. stream.finalMessage()resolves once the stream has ended, using an internal accumulator that reassembles all content blocks and deltas into a singleMessageobject identical in shape to a non-streaming response.- You do not have to iterate the events at all if you only care about the final result: calling
await stream.finalMessage()directly (without afor awaitloop) still works, since the SDK drives the stream to completion internally.
Event Types at a Glance
| Event type | When it fires | Useful fields |
|---|---|---|
message_start | Once, at the start of the response | message (id, model, role, empty content) |
content_block_start | Once per content block | index, content_block (type: text, tool_use, thinking) |
content_block_delta | Repeatedly, as tokens arrive | delta (text_delta, thinking_delta, or input_json_delta) |
content_block_stop | Once per content block, when it's complete | index |
message_delta | Near the end, with top-level changes | delta.stop_reason, usage |
message_stop | Once, when the stream is fully done | (no payload) |
Async Iterators vs. Manual Event Listeners
// Older style: manual event-listener wiring.
const stream = client.messages.stream({
model: "claude-sonnet-5",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello, Claude" }],
});
stream.on("text", (textDelta) => {
process.stdout.write(textDelta);
});
stream.on("error", (error) => {
console.error("Stream error:", error);
});
stream.on("end", () => {
console.log("\nDone.");
});
// Modern style: one async iterator loop, shown in the Recipe and Working Example above.The listener style still ships in the SDK for backward compatibility, but it has three practical downsides next to for await...of:
- Scattered control flow. Success, error, and completion each live in a separate callback, so there is no single place to reason about "what happens during this stream."
- Awkward cleanup. Breaking out of a stream early (say, after the user cancels a request) means manually calling
stream.abort()and unregistering listeners, instead of justbreak-ing a loop. - Weaker typing at the call site. Event names like
"text"are strings you have to get right, versusevent.typevalues that TypeScript narrows for you inside the loop.
TypeScript Notes
import type { MessageStreamEvent } from "@anthropic-ai/sdk/resources/messages";
function handleEvent(event: MessageStreamEvent): void {
// event.type narrows the union, so event.delta and event.content_block
// are only accessible with the fields that variant actually has.
if (event.type === "content_block_delta") {
if (event.delta.type === "text_delta") {
console.log(event.delta.text); // string, no cast needed
} else if (event.delta.type === "input_json_delta") {
console.log(event.delta.partial_json); // string, tool input fragment
}
}
}The SDK ships its own type definitions, so every event and content block is a discriminated union keyed on type. Narrowing with if/switch on event.type (and event.delta.type one level down) gives you exhaustive, compiler-checked handling instead of loosely-typed callback payloads.
Parameters & Return Values
| Parameter | Type | Description |
|---|---|---|
model | string | Model ID, e.g. "claude-sonnet-5". |
max_tokens | number | Upper bound on tokens generated for this response. |
messages | MessageParam[] | Conversation history passed to .stream(), same shape as the non-streaming .create() call. |
stream.finalMessage() | Promise<Message> | Resolves to the fully assembled message after the stream ends. |
stream.abort() | () => void | Cancels the underlying connection; safe to call from inside or outside the loop. |
Gotchas
- Iterating the stream twice. A
MessageStreamcan only be consumed once; callingfor awaiton it a second time yields nothing. Fix: capture what you need (text, usage, tool calls) during the single pass, or callstream.finalMessage()if you only need the end result. - Forgetting to narrow
event.delta.type. Treating everycontent_block_deltaas a text delta breaks when athinking_deltaorinput_json_deltaarrives, since those variants use different field names. Fix: always checkevent.delta.typebefore readingevent.delta.text. - Mixing
.on()listeners with afor awaitloop on the same stream. Both consume the same underlying events, so combining them leads to missed or duplicated data. Fix: pick one style per stream; use the async iterator unless you have a specific reason to keep listeners. - Not handling stream errors. An unhandled rejection from a broken connection or an API error mid-stream will crash a Node process if nothing awaits or catches it. Fix: wrap the loop (or the calling async function) in
try/catch, or attach a.catch()to the outer promise. - Calling
finalMessage()before the loop finishes. If you callstream.finalMessage()concurrently while still iterating in another part of the code, you can end up racing the same stream. Fix: callfinalMessage()after thefor awaitloop completes, or skip the loop entirely and only callfinalMessage()if you don't need per-token updates. - Assuming
content_block_startalways means text. Tool-use blocks and thinking blocks also firecontent_block_start, with a differentcontent_block.type. Fix: checkcontent_block.typebefore assuming the block istext.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Manual event listeners (.on('text', ...)) | You're maintaining older code already built around listeners, or you need multiple independent subscribers on one stream. | You're writing new code; the async iterator is simpler and less error-prone. |
Non-streaming client.messages.create(...) | The response is short, latency to first token doesn't matter, and you just want the final text. | You need to render partial output as it arrives, e.g. a chat UI. |
| Server-sent events proxied to a browser client | You're forwarding a Node-side stream to a frontend over HTTP. | You're consuming the stream entirely server-side; there's no need for an extra transport hop. |
FAQs
What's the difference between iterating the stream and calling stream.finalMessage()?
- Iterating (
for await...of) gives you every individual event as it arrives, useful for rendering partial output. stream.finalMessage()resolves once, after the stream ends, with the complete assembledMessage.- You can do both: iterate for incremental rendering, then call
finalMessage()afterward to get usage/stop_reason without re-parsing events yourself.
Can I use for await...of without ever calling .on() at all?
Yes. The async iterator is the primary, idiomatic way to consume a stream in current SDK versions. Event listeners are kept for backward compatibility, not because they're required.
Why does event.delta have different shapes in the loop?
content_block_delta events carry a delta field that is itself a discriminated union: text_delta, thinking_delta, or input_json_delta, depending on what kind of content block is being streamed. Check event.delta.type before reading type-specific fields like text or partial_json.
How do I stop a stream early, like when a user cancels a request?
const stream = client.messages.stream({ /* ... */ });
const timeout = setTimeout(() => stream.abort(), 5000);
for await (const event of stream) {
// ... handle events
}
clearTimeout(timeout);Calling stream.abort() closes the underlying connection; the for await loop then exits without throwing.
Does this pattern work in edge runtimes, not just Node.js?
Yes. The SDK is built on fetch and standard async iterators, both of which are available in edge runtimes (Vercel Edge, Cloudflare Workers) as well as Node.js.
What happens if I try to iterate the same stream object twice?
The second for await loop yields no events, since the stream has already been fully consumed. Capture any data you need during the first pass, or re-issue the request with .stream(...) again if you need a fresh stream.
How do I get just the plain text as it streams in, without handling every event type?
const stream = client.messages.stream({
model: "claude-sonnet-5",
max_tokens: 1024,
messages: [{ role: "user", content: "Hi" }],
});
for await (const text of stream.textStream ?? []) {
process.stdout.write(text);
}If your SDK version exposes a textStream async iterable convenience property, it filters events down to just text deltas. Otherwise, filter content_block_delta events where delta.type === "text_delta" yourself, as shown in the Working Example.
Is message_delta the same as content_block_delta?
No. content_block_delta carries incremental content (text, thinking, or tool input JSON) for one content block. message_delta carries top-level changes to the message as a whole, such as the final stop_reason and cumulative usage, and fires near the end of the stream.
Do I need try/catch around the for await loop?
Yes, for production code. Network errors, rate limits, or API errors during streaming surface as thrown exceptions from the loop (or a rejected promise from finalMessage()), so wrap the call in try/catch or attach a .catch() to the enclosing async function.
Can I combine tool use and text streaming in the same loop?
Yes. A single response can include both text and tool_use content blocks. Check content_block.type in content_block_start and event.delta.type in content_block_delta to branch between text deltas and input_json_delta (partial tool-call arguments) within the same loop.
Why is my thinking_delta output different from text_delta?
thinking_delta events carry their content under event.delta.thinking, not event.delta.text. They only appear when extended thinking is enabled for the request and belong to a thinking content block rather than a text block.
Does using async iterators change how I set up the client or the request?
No. You still construct the client with new Anthropic() and pass the same model, max_tokens, and messages fields. The only difference is calling .stream(...) instead of .create(...), and then consuming the result with for await instead of awaiting a single response.
Related
- TypeScript/JavaScript SDK Basics - client setup and request basics this page builds on.
- The Anthropic TypeScript SDK Mental Model - how requests, responses, and streams fit together conceptually.
- TypeScript SDK Type Reference for Content Blocks - full type reference for
TextBlock,ToolUseBlock, andThinkingBlock. - Building a Streaming Chat Endpoint with Next.js and the TypeScript SDK - applies this streaming pattern inside a Next.js route handler.
- Retry, Timeout, and AbortController Patterns in the TypeScript SDK - canceling and retrying streams safely.
Stack versions: Written against the Claude model lineup current as of ~June 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (the default), and Claude Haiku 4.5 - and the official
@anthropic-ai/sdkTypeScript SDK (latest release). Model names, SDK versions, and pricing move quickly - verify current specifics at platform.claude.com/docs before relying on them.