Streaming to the Browser
Your server streams from Claude and forwards each chunk to the browser, which appends it live.
In a web app, the browser must never call Claude directly (the API key would leak). Instead, your server streams from Claude and re-streams the text to the browser.
The pipeline
- The browser requests an answer from your endpoint.
- Your server calls Claude with streaming on.
- As chunks arrive, your server writes them to the browser response.
- The browser appends each chunk to the page.
Example
// Express route: stream Claude's reply to the browser as plain text chunks
app.post("/api/chat", async (req, res) => {
res.setHeader("Content-Type", "text/plain; charset=utf-8");
const stream = client.messages.stream({
model: "claude-opus-4-8",
max_tokens: 2048,
messages: [{ role: "user", content: req.body.message }],
});
stream.on("text", (delta) => res.write(delta)); // forward each chunk
await stream.finalMessage();
res.end();
});
// In the browser: read the response body stream and append to the page
// const reader = (await fetch('/api/chat', {...})).body.getReader();When to use it
- Forward Claude SSE chunks from your Express server to the browser using the Fetch API with ReadableStream.
- Display a typing indicator in the chat UI that disappears only when the stream_stop event arrives.
- Append each streamed token to a contenteditable div so the user sees text appear character by character.
More examples
Server: stream Claude to the client
Sets SSE headers and forwards each text delta as a data: line so the browser can read it with EventSource.
// Express route β streams Claude's response as SSE
app.post("/api/chat", async (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
const stream = await client.messages.stream({
model: "claude-haiku-4-5",
max_tokens: 512,
messages: req.body.messages,
});
for await (const chunk of stream) {
if (chunk.type === "content_block_delta") {
res.write(`data: ${JSON.stringify({ text: chunk.delta.text })}\n\n`);
}
}
res.write("data: [DONE]\n\n");
res.end();
});Browser: read the SSE stream
Uses the Fetch ReadableStream API to process incoming SSE lines and append each text chunk to the DOM.
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: userInput }] }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder.decode(value).split("\n");
for (const line of lines) {
if (line.startsWith("data: ") && line !== "data: [DONE]") {
const { text } = JSON.parse(line.slice(6));
outputEl.textContent += text;
}
}
}Show typing indicator while streaming
Shows a typing indicator before the loop starts and hides it when the stream is exhausted.
const indicator = document.getElementById("typing-indicator");
indicator.hidden = false;
const response = await fetch("/api/chat", { /* ...options */ });
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// append text chunks...
}
indicator.hidden = true; // hide when stream ends
Discussion