Two hours into my first Claude API streaming implementation, I hit ReadableStream is not async iterable and couldn't figure out why the official sample worked fine but my Next.js integration didn't. If you've been there, this article is for you.
These are the three pitfalls I've run into while building production applications with Claude API streaming — things that aren't obvious from the documentation alone.
Pitfall 1: Runtime Environment Mismatches Break Async Iteration
The Anthropic SDK's stream() method returns an SDK-specific wrapper object that behaves as an AsyncIterable, but it's not the same as a Web standard ReadableStream. This distinction matters a lot in Next.js App Router, where API routes can run in Edge Runtime by default.
// ❌ Pattern that breaks in Edge Runtime
import Anthropic from '@anthropic-ai/sdk';
export const runtime = 'edge'; // This causes the problem
const client = new Anthropic();
export async function POST(req: Request) {
const stream = await client.messages.stream({
model: 'claude-opus-4-6',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Hello' }],
});
// for await works locally (Node.js dev server) but breaks on Edge
for await (const chunk of stream) {
// ...
}
}The local Next.js dev server runs on Node.js, so this works fine during development. It only breaks in production where the Edge Runtime is actually used — which makes it especially painful to debug.
There are two fixes: switch explicitly to Node.js Runtime, or convert to a Web standard ReadableStream using the SDK's built-in utility.
// ✅ Stable pattern using Node.js Runtime
export const runtime = 'nodejs'; // Explicit and safe
export async function POST(req: Request) {
const client = new Anthropic();
const stream = client.messages.stream({
model: 'claude-opus-4-6',
max_tokens: 2048,
messages: [{ role: 'user', content: await req.text() }],
});
// toReadableStream() converts to Web standard ReadableStream
return new Response(stream.toReadableStream(), {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}The toReadableStream() method is the SDK's bridge between its own streaming abstraction and the Web standard. Once I understood this, a lot of the confusion cleared up.
Pitfall 2: Two Types of Errors, One try-catch
With streaming, errors fall into two distinct categories: errors that happen before the stream starts (connection failures, authentication errors, invalid parameters) and errors that happen during the stream (network drops, rate limiting mid-response). Handling both with a single try-catch often means one type slips through.
// ❌ One try-catch misses mid-stream errors
async function streamMessage(content: string) {
try {
const stream = client.messages.stream({ ... });
for await (const event of stream) {
process.stdout.write(event.delta?.text ?? '');
}
} catch (e) {
// Pre-stream errors: caught here
// Mid-stream errors: may not reach here reliably
console.error(e);
}
}The SDK's event emitter pattern combined with finalMessage() gives you proper control over both error types.
// ✅ Handling both error types explicitly
async function streamMessage(content: string): Promise<string> {
const stream = client.messages.stream({
model: 'claude-opus-4-6',
max_tokens: 2048,
messages: [{ role: 'user', content }],
});
// Mid-stream errors: handled via event listener
stream.on('error', (error) => {
console.error('Stream error during generation:', error);
// Add retry logic here if needed
});
// Process text incrementally
stream.on('text', (text) => {
process.stdout.write(text);
});
// Pre-stream errors: thrown from finalMessage()
// This also ensures we wait for the stream to fully complete
try {
const finalMessage = await stream.finalMessage();
return finalMessage.content[0].type === 'text'
? finalMessage.content[0].text
: '';
} catch (e) {
throw new Error(`Stream failed: ${e instanceof Error ? e.message : String(e)}`);
}
}The key insight here is that finalMessage() serves double duty: it signals successful stream completion and surfaces pre-stream errors as exceptions. Without it, you might process partial responses without realizing something went wrong.
Pitfall 3: Silent Token Cost Overruns
Streaming creates a psychological effect where "the response is flowing" feels like everything is working correctly. Meanwhile, token consumption can grow far beyond what you'd expect — especially in conversational applications where you're passing full message history.
The finalMessage() response includes a usage field with actual token counts:
const finalMessage = await stream.finalMessage();
console.log({
inputTokens: finalMessage.usage.input_tokens,
outputTokens: finalMessage.usage.output_tokens,
totalTokens: finalMessage.usage.input_tokens + finalMessage.usage.output_tokens,
model: finalMessage.model,
});In one of my projects, I was passing the full conversation history to messages on every request. By turn 30, input tokens were exceeding 15,000 per request — something I only noticed when the monthly bill arrived. Conversation trimming should be part of your initial design, not an afterthought.
// Practical token budget enforcement
const MAX_INPUT_TOKENS = 8000;
function trimMessages(
messages: Anthropic.MessageParam[],
maxTokens: number
): Anthropic.MessageParam[] {
// Rough estimate: ~500 tokens per message average
const estimatedPerMessage = 500;
const maxMessages = Math.floor(maxTokens / estimatedPerMessage);
if (messages.length <= maxMessages) return messages;
// Preserve the first message (often contains important context)
const firstMessage = messages[0];
const recentMessages = messages.slice(-(maxMessages - 1));
return [firstMessage, ...recentMessages];
}For exact counts, client.messages.countTokens() works, but calling it before every streaming request adds latency and API costs. A threshold-based approach — trim when conversation length exceeds N messages — is usually more practical.
The Underlying Challenge
Looking back, streaming is tricky precisely because "mostly working" is easy to achieve. Text flows, the UI looks responsive, no obvious errors. But incomplete error handling and invisible token overruns can create real problems in production.
The right mental model is to design for failure from the start: what happens when the connection drops halfway through? What's the token budget per conversation? How will you log and monitor usage? These questions are much easier to answer before your architecture is set.
Claude API streaming can dramatically improve user experience when implemented well. Getting there requires understanding what the documentation leaves implicit — hopefully this article helps bridge that gap.