Building a Chat UI with Incremental Token Deltas
A chat interface that waits for Claude's full answer before showing anything feels sluggish, even when the total response time is the same.
Search across all documentation pages
A chat interface that waits for Claude's full answer before showing anything feels sluggish, even when the total response time is the same.
Rendering text_delta events as they arrive - updating a message bubble token by token - is what makes a chat UI feel alive.
This page builds that rendering loop up from a terminal prototype to a pattern you can wire into a real backend endpoint.
A chat UI built on streaming has three layers: a stream consumer that reads events, an accumulator that tracks the growing message text, and a renderer that redraws the UI with each update.
The anthropic Python SDK's text_stream iterator handles the first layer for the common case of plain text.
The accumulator is just a mutable string (or list of chunks) your application updates on every delta.
The renderer depends on your stack - a terminal print, a WebSocket send, or a Server-Sent Events response back to a browser - but the pattern feeds all of them the same accumulated text.
This page shows the terminal version first (fastest to verify), then a FastAPI endpoint that re-streams Claude's output to a browser.
import anthropic
client = anthropic.Anthropic()
def stream_chat_message(prompt: str) -> str:
full_text = ""
with client.messages.stream(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
) as stream:
for delta in stream.text_stream:
full_text += delta
render(full_text) # your UI update function
return full_text
def render(current_text: str) -> None:
print(f"\r{current_text}", end="", flush=True)When to reach for this:
A minimal FastAPI backend that re-streams a chat turn to the browser over Server-Sent Events, and a matching browser-side consumer.
# server.py
import json
import anthropic
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
app = FastAPI()
client = anthropic.Anthropic()
class ChatRequest(BaseModel):
message: str
def event_generator(user_message: str):
with client.messages.stream(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": user_message}],
) as stream:
for delta in stream.text_stream:
payload = json.dumps({"delta": delta})
yield f"data: {payload}\n\n"
yield "data: [DONE]\n\n"
@app.post("/chat")
def chat(request: ChatRequest):
return StreamingResponse(
event_generator(request.message),
media_type="text/event-stream",
)// client.js (browser)
async function sendMessage(message, onDelta) {
const response = await fetch("/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6);
if (data === "[DONE]") return;
onDelta(JSON.parse(data).delta);
}
}
}What this demonstrates:
text_delta becomes one small JSON payload the browser appends to a growing message bubble.[DONE] sentinel tells the browser when to stop reading, separate from Claude's own message_stop.stream.text_stream filters the raw event stream down to just text_delta fragments, in order, discarding block markers and metadata events.[DONE]) decouples your frontend's "stream finished" signal from Claude's message_stop, which is useful if you ever want to send trailing metadata (like usage) after the text finishes.| Strategy | Update cost | Best for |
|---|---|---|
| Re-render full text on every delta | O(n) per redraw in text length | Terminal prototypes, small messages |
| Append-only DOM/text mutation | O(1) per delta | High-frequency updates in a raw DOM (no virtual DOM) |
| Batch deltas, flush on a timer (e.g. every 50ms) | Amortized, smoother frame rate | Production chat UIs with many concurrent streams |
# Batch small deltas into fewer UI updates using a simple timer-based flush.
import time
def stream_with_batched_updates(prompt: str, flush_interval: float = 0.05):
buffer = []
last_flush = time.monotonic()
full_text = ""
with client.messages.stream(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
) as stream:
for delta in stream.text_stream:
buffer.append(delta)
if time.monotonic() - last_flush >= flush_interval:
full_text += "".join(buffer)
render(full_text)
buffer.clear()
last_flush = time.monotonic()
if buffer:
full_text += "".join(buffer)
render(full_text)
return full_texttext_delta breaks on word or sentence boundaries - it does not, and code that tries to detect "end of sentence" mid-delta will misfire. Fix: buffer to the accumulated string and run sentence detection on that, not on individual deltas.request.is_disconnected() in FastAPI (or the equivalent for your framework) and break out of the generator.text_stream with manual event iteration on the same stream object - the SDK's stream is a single-pass iterator; consuming it two different ways produces incomplete results. Fix: pick one consumption style (text_stream or raw for event in stream) per stream and stick to it.message_stop before calling get_final_message() - calling it too early can return a partially built message. Fix: only call get_final_message() after the stream's iteration has fully completed.| Alternative | Use When | Don't Use When |
|---|---|---|
| Polling for the full response | Simplicity matters more than latency; batch/background jobs | The user is actively watching the response generate |
| WebSockets instead of SSE for the browser leg | You already need bidirectional messaging (e.g. mid-stream interrupts) | A simple one-way relay is enough - SSE is less infrastructure to run |
| Client-side streaming directly to Claude (no backend relay) | Prototyping only, never production | You need to protect your API key or apply rate limiting/auth |
Always relay through your own backend in production. Calling the Anthropic API directly from a browser means shipping your API key to every client, which is a credential leak waiting to happen.
with client.messages.stream(
model="claude-sonnet-5",
max_tokens=200,
messages=[{"role": "user", "content": "Hi"}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)For a browser UI with many DOM updates, yes - flushing every 25-50ms instead of on every delta noticeably reduces render overhead without a perceptible latency cost to the user.
Your own [DONE] sentinel (or equivalent) on your relay stream is the cleanest signal for the frontend; internally, it corresponds to Claude's message_stop event completing on the backend side.
Not directly - each client.messages.stream(...) call is a single Claude API stream. To fan out to multiple viewers, consume it once on your backend and re-broadcast the deltas over your own pub/sub or WebSocket layer.
Nothing automatically stops the Claude API call - your backend needs to detect the client disconnect and break out of the streaming loop, or you'll keep consuming (and paying for) tokens the user never sees.
text_stream is a convenience wrapper that filters raw events down to just text_delta fragments. For anything beyond plain text (tool calls, thinking), you need the raw event iterator instead.
Frequent full re-renders of a growing string can cause layout thrash in some frontend frameworks. Batching deltas over a short interval before triggering a state update usually resolves it.
No - your backend should translate Claude's event stream into whatever shape your frontend expects (often a simpler { delta: string } JSON payload), not forward Claude's raw SSE frames untouched.
Show it as soon as the request starts, and clear it on the first text_delta (or on content_block_start for the text block) - there's typically a short gap between sending the request and the first token.
Yes - exiting the with client.messages.stream(...) block (e.g. via break in the loop, or an exception) closes the underlying connection, stopping further generation from being billed as output for that call.
Text deltas are directly displayable as they arrive. Tool call arguments stream as JSON fragments that are not valid JSON until complete, so they must be accumulated and parsed differently - see Handling Partial JSON During Streamed Tool Calls.
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
anthropicPython SDK (latest 0.x release). Model names, SDK versions, and pricing move quickly - verify current specifics at platform.claude.com/docs before relying on them.
Reviewed by Chris St. John·Last updated Jul 19, 2026