DeepFrontend
Practice ArenaBlogSign in
Go Pro
DeepFrontend

Interview-grade learning paths and hands-on practice for mid-to-senior engineers. Master internals, patterns, and system architecture with runnable code.

All systems operational

Core Courses

  • -JavaScript Internals
  • -React Reconciliation
  • -TypeScript rigor
  • -Next.js Caching
  • -Node.js Event Loop
  • -System Design
  • -Web Security
  • -CSS & Page Layouts

Explore

  • Learning Paths
  • Practice Arena
  • Pricing Options
  • HTML Sitemap

Resources

  • Privacy Policy
  • Terms of Service
  • Email Support

© 2026 DeepFrontend. All rights reserved.

Expert learning environments for web engineering teams.

Home/AI for Frontend Developers/Streaming Responses in UI
intermediate25 min read·Updated Jul 2026

Streaming Responses in UI

The complete, confirmed mechanics of streaming a Claude response into a real UI — the exact SSE event sequence (message_start, content_block_start/delta/stop, message_delta, message_stop) emitted by the currently-installed @anthropic-ai/sdk, why streaming is required above roughly 16K output tokens to avoid HTTP timeouts, and how a paused tool-use turn differs from a finished one.

streamingsseserver-sent-eventschat-uiaianthropic

Knowledge Check

16 questions · pass at 70%

0/16
easymcq

1. Which SSE event type carries the incremental text fragments a client should render?


Interview Questions

2 questions

Cheatsheet

Download-ready reference
Cheatsheet
STREAMING CALL:
  const stream = client.messages.stream({ model, max_tokens, messages });
  for await (const event of stream) { ... }
  const finalMessage = await stream.finalMessage(); // full Message, incl. usage

EVENT SEQUENCE (fixed order, every normal turn):
  message_start        → metadata only, NO text yet
  content_block_start  → announces a block's type (text | tool_use | thinking)
  content_block_delta  → incremental fragment:
                            text_delta        → delta.text   (APPEND, never assign)
                            input_json_delta  → delta.partial_json (accumulate, parse once at stop)
                            thinking_delta    → delta.thinking
  content_block_stop   → one block done (may repeat for multiple blocks)
  message_delta        → delta.stop_reason + running usage
  message_stop         → entire message complete

RULE #1: renderedText += event.delta.text     (NOT =)
RULE #2: JSON.parse(accumulated) only AFTER content_block_stop for that block

WHEN STREAMING IS REQUIRED, NOT OPTIONAL:
  Non-streaming requests risk HTTP timeouts above ~16K expected output tokens.
  This applies even to backend jobs with no live UI consumer.

TOOL-USE PAUSE MID-STREAM:
  stop_reason: "tool_use"  → model is WAITING on you, not finished.
  Execute the tool, send a NEW request with a tool_result — stream for
  THIS turn has ended; the interaction continues on the next call.

BROWSER WIRING:
  Browser → your backend route (SSE/ReadableStream) → your backend → Claude stream
  Browser NEVER talks to Anthropic directly (API key stays server-side).

Related topics

AI SDK Integration (Anthropic, OpenAI)ProAI Chat Application PatternsProTool / Function CallingProAI UI PatternsPro

On this page

25m
Knowledge CheckInterview QuestionsCheatsheet

Concept#

A non-streaming client.messages.create() call waits for the entire response to finish generating before returning anything to your code — for a long answer, that can be many seconds of a blank screen. Streaming instead delivers the response as a sequence of incremental Server-Sent Events (SSE) as the model generates each piece, which is what makes the token-by-token "typing" effect you see in every production AI chat UI possible.

The exact event sequence#

import Anthropic from "@anthropic-ai/sdk";
 
const client = new Anthropic();
 
const stream = client.messages.stream({
  model: "claude-opus-4-8",
  max_tokens: 1024,
  messages: [{ role: "user", content: "Explain TCP handshakes in two sentences." }],
});
 
for await (const event of stream) {
  if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
    process.stdout.write(event.delta.text);
  }
}
 
const finalMessage = await stream.finalMessage();
console.log("\nTokens used:", finalMessage.usage.output_tokens);

Six event types make up a normal streamed turn, always in this order:

client.messages.stream() — SSE events arrive as content_block_delta, text builds incrementally
step 1 / 5
const stream = client.messages.stream({
model: 'claude-opus-4-8',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Explain TCP handshakes' }],
});
Client
active
opens HTTP connection
→
Server
waiting
not yet responding
Rendered UI

…

Calling .stream() opens a long-lived connection instead of waiting for one full response. Nothing has arrived yet — the UI should show a pending state, not a blank one.

EventFiresWhat it carries
message_startOnce, firstMessage metadata — id, model, empty usage. No text yet.
content_block_startWhen a new block beginsThe block's type (text, tool_use, thinking)
content_block_delta

The client's job at the delta stage is nothing more than string concatenation: append delta.text to whatever's already rendered. Never replace the rendered text — deltas are fragments, not full snapshots.

Why streaming is required, not just nice-to-have, above a size threshold#

Non-streaming requests are held open by both the SDK's HTTP client and (depending on your deployment) an infrastructure-level proxy timeout. For any response likely to exceed roughly 16,000 output tokens, a non-streaming call risks hitting one of those timeouts before the model finishes — the request simply fails, even though the model would have eventually produced a complete answer. Streaming sidesteps this because the connection is actively delivering bytes the whole time, so it's never "idle" long enough to trip a timeout.

// A long-form generation task — must stream, not just "may want to"
const stream = client.messages.stream({
  model: "claude-opus-4-8",
  max_tokens: 64000, // well above the ~16K non-streaming safety threshold
  messages: [{ role: "user", content: "Write a detailed technical design doc for..." }],
});

Wiring a stream to a real UI: Server-Sent Events over HTTP#

In a browser-facing app, the pattern is: your backend route opens a Claude stream, and re-emits it to the browser as its own SSE response (or you adapt it to a ReadableStream for fetch-based consumption). The browser never talks to Anthropic directly (see the SDK integration topic for why).

// app/api/chat/route.ts — Next.js Route Handler streaming back to the browser
import Anthropic from "@anthropic-ai/sdk";
 
const client = new Anthropic();
 
export async function POST(req: Request) {
  const { prompt } = await req.json();
 
  const claudeStream = client.messages.stream({


















On the client, a simple fetch with a readable-stream body reader is enough to render tokens as they arrive — no special browser API beyond what fetch already provides for reading a streamed response body incrementally.

A streamed turn that pauses for a tool call#

Streaming and tool use compose — a streamed response can include ordinary text, then a tool_use block whose input fills in incrementally via input_json_delta fragments, then stop with stop_reason: "tool_use" instead of "end_turn".

A streamed turn that calls a tool: text deltas, then an input_json_delta block, then stop_reason: tool_use
step 1 / 4
// content_block_start — index 0, type: 'text'
// a few text_delta events render normally
Block 0 (text)
active
"Let me check the weather..."
→
Block 1 (tool_use)
idle
not started
Rendered UI

Let me check the weather for you.

Claude can stream ordinary explanatory text before deciding to call a tool — this is not an error state, just an earlier content block in the same message.

The critical detail: input_json_delta fragments are partial JSON strings. Concatenate them as you receive them, but only call JSON.parse() on the fully accumulated string once content_block_stop fires for that block — parsing a partial fragment mid-stream will throw, since {"locat is not valid JSON on its own.


Try It#

Predict what the rendered UI text looks like after these three deltas arrive, in order, before checking the solution.

// delta 1: { type: "text_delta", text: "The " }
// delta 2: { type: "text_delta", text: "quick brown " }
// delta 3: { type: "text_delta", text: "fox." }

If a beginner's code does renderedText = event.delta.text (assignment) instead of renderedText += event.delta.text (concatenation) inside the loop, what does the final rendered UI show?

Solution

"fox." — just the last delta, with everything before it silently overwritten. Each content_block_delta event carries only its own small text fragment, not the full accumulated text so far — the client is responsible for concatenating every fragment itself. Using = instead of += is one of the most common streaming bugs: it looks correct in a quick manual test (since the LAST delta happens to contain the tail of a real sentence, which can look plausible on its own), but it silently discards every earlier fragment, producing truncated or nonsensical output for any response longer than a single delta chunk.


Implement It Yourself#

Build a minimal hand-rolled SSE line parser — the actual mechanism a fetch-based streaming client (or the SDK internally) uses to turn a raw byte stream into individual events, without any external streaming library.

async function parseSSEStream(response: Response, onEvent: (eventType: string, data: unknown) => void) {
  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let buffer =

































This is close to the real mechanism: SSE frames are text, delimited by blank lines, each with an event: line and a data: line — parsing them by hand makes it obvious why buffering an incomplete trailing frame (the buffer = frames.pop() line) matters, since a chunk boundary from the network can split one logical frame across two reader.read() calls.


Under the Hood#

The non-blocking, incremental nature of consuming a stream — for await (const event of stream) never blocks the rest of your server process while waiting for the next chunk — is the same asynchronous I/O model that underlies The Node.js Event Loop and, more generally, The JavaScript Event Loop: a stream is conceptually a sequence of deferred callbacks/microtasks resolving over time, not a single synchronous blocking read. On the wire, re-emitting Claude's stream to the browser as your own SSE response is exactly the request/response streaming model covered in Building an HTTP Server in Node.js — your Route Handler is itself acting as an HTTP server sending a chunked, long-lived response body.


Common Mistakes#

1. Assigning instead of concatenating rendered text#

// ❌ overwrites everything each delta, final UI shows only the LAST fragment
let rendered = "";
for await (const event of stream) {
  if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
    rendered = event.delta.text; // should be +=
  }
}

Deltas are fragments, not cumulative snapshots — always append, never replace.

2. Parsing input_json_delta fragments as JSON individually#

// ❌ throws — partial JSON fragments aren't valid JSON on their own
for await (const event of stream) {
  if (event.delta?.type === "input_json_delta") {
    const parsed = JSON.parse(event.delta.partial_json); // "{\"locat" is not valid JSON
  }
}

Accumulate every partial_json fragment into one string across the whole block, and only call JSON.parse() once, after content_block_stop fires for that block.

3. Treating a non-streaming request as always safe regardless of expected output length#

// ❌ risks an HTTP timeout for a long-form generation task
const response = await client.messages.create({
  model: "claude-opus-4-8",
  max_tokens: 64000, // large expected output, but NOT streaming
  messages: [{ role: "user", content: "Write an exhaustive technical spec for..." }],
});

Above roughly 16,000 expected output tokens, use client.messages.stream() instead — a non-streaming call that large risks failing on an HTTP timeout partway through generation, wasting the tokens the model already produced.


Best Practices#

  • Always concatenate delta.text, never assign — a stream's rendered output is the sum of every fragment received so far, not just the latest one.
  • Buffer input_json_delta fragments and parse only after content_block_stop — partial JSON is not valid JSON.
  • Use client.messages.stream() for any response that might exceed ~16K output tokens — this isn't an optimization, it's what prevents an otherwise-successful generation from failing on an HTTP timeout.
  • Call stream.finalMessage() (or the equivalent await pattern) at the end to get the fully-assembled Message object — including usage and stop_reason — instead of hand-tracking every field yourself.

Performance Tips#

  • Buffer a handful of tokens client-side before triggering a DOM re-render, rather than re-rendering on every single delta — many chat UIs batch deltas into ~50–100ms render ticks to avoid excessive reflow on very fast streams.
  • Prefer the SDK's stream.on("text", ...) event (where available) over manually filtering every raw content_block_delta — it hands you just the delta string directly.
  • Reuse one long-lived HTTP connection per turn rather than polling — SSE's whole design point is to avoid the overhead and latency of a request-per-update polling loop.

Per incremental chunk
delta.type: "text_delta" with a text fragment (or thinking_delta, input_json_delta)
content_block_stopWhen a block finishes—
message_deltaNear the enddelta.stop_reason and running usage
message_stopOnce, lastStream is fully done
model: "claude-opus-4-8",
max_tokens: 4096,
messages: [{ role: "user", content: prompt }],
});
const encoder = new TextEncoder();
const body = new ReadableStream({
async start(controller) {
for await (const event of claudeStream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
controller.enqueue(encoder.encode(event.delta.text));
}
}
controller.close();
},
});
return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
}
""
;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by a blank line; each frame has
// "event: <type>\ndata: <json>\n\n"
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? ""; // last (possibly incomplete) frame stays buffered
for (const frame of frames) {
const lines = frame.split("\n");
let eventType = "message";
let dataLine = "";
for (const line of lines) {
if (line.startsWith("event: ")) eventType = line.slice(7);
if (line.startsWith("data: ")) dataLine = line.slice(6);
}
if (dataLine) {
onEvent(eventType, JSON.parse(dataLine));
}
}
}
}
// Usage against a raw SSE endpoint:
let rendered = "";
await parseSSEStream(response, (eventType, data: any) => {
if (eventType === "content_block_delta" && data.delta?.type === "text_delta") {
rendered += data.delta.text; // concatenate — never assign
}
});
  • Never let the browser talk to Anthropic directly — stream through your own backend route, which itself streams to the client via SSE or a ReadableStream.