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.
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.
Summary
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.
Recipe
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:
- Any chat, assistant, or copilot surface where a user is actively watching the response generate.
- Live document or code generation panels that should fill in progressively.
- Voice or narration pipelines that want to start text-to-speech on the first sentence rather than the whole response.
- Long-form answers where showing partial progress reduces perceived latency even if total time is unchanged.
Working Example
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:
- Claude's SSE stream is re-wrapped into a second, application-level SSE stream so the browser never talks to the Anthropic API directly (keeping the API key server-side).
- Each
text_deltabecomes one small JSON payload the browser appends to a growing message bubble. - A
[DONE]sentinel tells the browser when to stop reading, separate from Claude's ownmessage_stop. - The browser reads raw bytes and manually splits on blank lines - this is the same SSE framing described in How Server-Sent Events Power Claude's Streaming Responses, just implemented by hand on the client side.
Deep Dive
How It Works
stream.text_streamfilters the raw event stream down to justtext_deltafragments, in order, discarding block markers and metadata events.- The accumulator pattern - append each delta to a running string - is what lets a UI redraw the full message on every update instead of only appending, which matters for UIs that re-render from state (like React) rather than mutating the DOM directly.
- Re-streaming through your own backend (rather than exposing the Anthropic API key to the browser) is standard practice; your endpoint becomes a thin relay that reshapes Claude's SSE stream into whatever transport your frontend expects.
- Ending your own stream with an explicit sentinel (
[DONE]) decouples your frontend's "stream finished" signal from Claude'smessage_stop, which is useful if you ever want to send trailing metadata (like usage) after the text finishes.
Rendering Strategies at a Glance
| 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 |
Python Notes
# 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_textGotchas
- Redrawing the whole message string on every single delta - fine in a terminal demo, but expensive in a browser UI with many DOM nodes. Fix: batch deltas over a short interval (25-50ms) before triggering a re-render.
- Sending the API key to the browser to call Claude directly - exposes your credentials to anyone who opens dev tools. Fix: always proxy through your own backend endpoint, as shown above.
- Assuming
text_deltabreaks 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. - Forgetting to close the SSE response when the client disconnects - a browser tab closing mid-stream leaves your backend still consuming Claude's stream and paying for tokens nobody sees. Fix: check
request.is_disconnected()in FastAPI (or the equivalent for your framework) and break out of the generator. - Mixing
text_streamwith 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_streamor rawfor event in stream) per stream and stick to it. - Not handling
message_stopbefore callingget_final_message()- calling it too early can return a partially built message. Fix: only callget_final_message()after the stream's iteration has fully completed.
Alternatives
| 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 |
FAQs
Do I have to re-stream through my own backend, or can the browser call Claude directly?
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.
What's the simplest way to show streaming text in a terminal?
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)Should I batch deltas before updating the UI?
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.
How do I know when the assistant's message is fully done?
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.
Can I stream to multiple connected clients from one Claude request?
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.
What happens if the user closes the browser tab mid-response?
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.
Is `text_stream` the same thing as iterating raw events?
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.
Why does my chat UI flicker when I re-render the full string each time?
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.
Should the frontend parse Claude's SSE format directly?
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.
How do I add a typing indicator before the first token arrives?
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.
Can I cancel a stream partway through?
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.
What's the difference between this and streaming a tool call's arguments?
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.
Related
- How Server-Sent Events Power Claude's Streaming Responses - the event model this page renders.
- Streaming Responses Basics - the terminal-level streaming loop this builds on.
- Streaming Best Practices - buffering, reconnects, and error handling for production chat UIs.
- Streaming Extended Thinking Blocks to the Frontend - rendering a second stream (reasoning) alongside the chat text.
- Streaming Responses with the Python SDK - deeper coverage of the SDK's streaming helper.
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.