Stream Claude Responses to the Browser with Server-Sent Events
Build an Express app that pipes Claude's token-by-token output to the browser using Server-Sent Events, so text appears incrementally as it's generated.
What You'll Build
A small web app with an Express backend that pipes Claude's token stream to the browser using Server-Sent Events (SSE), so text appears incrementally instead of all at once.
Prerequisites
- Node.js 18+ — check with
node -v - An Anthropic API key — get one at console.anthropic.com
- Basic familiarity with JavaScript and the terminal
1. Create the Project
mkdir claude-sse && cd claude-sse
npm init -y
npm install express @anthropic-ai/sdk dotenv
Create a .env file (never commit this):
echo "ANTHROPIC_API_KEY=sk-ant-your-key-here" > .env
echo ".env" >> .gitignore
Enable ES modules by adding one line to package.json:
{
"type": "module"
}
2. Build the Express Server
Create server.js:
import 'dotenv/config';
import express from 'express';
import Anthropic from '@anthropic-ai/sdk';
const app = express();
app.use(express.json());
app.use(express.static('public'));
const client = new Anthropic(); // reads ANTHROPIC_API_KEY from env
app.post('/stream', async (req, res) => {
const { prompt } = req.body;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
const stream = await client.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
messages: [{ role: 'user', content: prompt }],
stream: true,
});
for await (const event of stream) {
if (
event.type === 'content_block_delta' &&
event.delta.type === 'text_delta'
) {
res.write(`data: ${JSON.stringify({ text: event.delta.text })}\n\n`);
}
}
res.write('data: [DONE]\n\n');
} catch (err) {
res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
} finally {
res.end();
}
});
app.listen(3000, () => console.log('Listening on http://localhost:3000'));
Why these headers? text/event-stream tells the browser this is an SSE response. Cache-Control: no-cache and Connection: keep-alive prevent buffers from holding data back.
3. Build the Frontend
Create public/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Claude Stream</title>
</head>
<body>
<textarea id="prompt" rows="3" cols="50" placeholder="Ask Claude something…"></textarea>
<br>
<button id="send">Send</button>
<pre id="output"></pre>
<script>
document.getElementById('send').addEventListener('click', async () => {
const prompt = document.getElementById('prompt').value.trim();
const output = document.getElementById('output');
output.textContent = '';
const response = await fetch('/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
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');
buffer = lines.pop(); // last element may be an incomplete line
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6);
if (payload === '[DONE]') return;
const parsed = JSON.parse(payload);
if (parsed.error) {
output.textContent += `\n[Error: ${parsed.error}]`;
return;
}
output.textContent += parsed.text;
}
}
});
</script>
</body>
</html>
Note: The browser uses
fetch+ReadableStreamrather thanEventSourcebecauseEventSourceonly supports GET requests, which can't carry a request body.
Two key details in this reader:
- Buffering: Each decoded chunk is appended to
buffer. After splitting on\n,lines.pop()returns the last (potentially incomplete) line tobufferso it is completed by the next chunk before being parsed. Without this, a JSON payload split across two network chunks causesJSON.parseto throw. - Error handling: If the server sends
{ "error": "…" }(e.g., on a rate-limit or bad API key), the frontend displays the message instead of silently appendingundefined.
4. Run It
node server.js
Open http://localhost:3000, type a question, and click Send.
Verify It Works
Text should appear word-by-word in the <pre> block. To confirm SSE frames at the network level:
- Open DevTools → Network tab
- Submit a prompt and click the
/streamrequest - Under EventStream (Chrome) or Response (Firefox), individual
data:frames will arrive in real time
Expected terminal output: Listening on http://localhost:3000 with no errors.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
401 Unauthorized |
Bad or missing API key | Verify the key in .env matches console.anthropic.com |
Cannot find package |
Missing dependencies | Re-run npm install |
| Output arrives all at once | Proxy or middleware buffering | Confirm the three res.setHeader calls are present; disable buffering in any reverse proxy (e.g., proxy_buffering off in nginx) |
[Error: ...] appears in output |
API error (rate limit, invalid key, etc.) | Check the error message and resolve the underlying API issue |
Next Steps
- Robust SSE parsing: The
eventsource-parserpackage is a battle-tested alternative to the manual buffer approach used here. - React / Next.js: The Vercel AI SDK wraps this pattern with
useChathooks and handles edge cases for you. - System prompts: Add a
system: 'You are a helpful assistant.'field tomessages.create()to give Claude a persona. - Model selection: Check the Anthropic models overview for the latest available model IDs.
Priya covers AI frameworks, developer productivity tooling, and the startup ecosystem across South and Southeast Asia, bringing a researcher's rigour and a practitioner's empathy to every story. She is deeply sceptical of benchmarks and asks hard questions so her readers don't have to.
Discussion 0
No comments yet
Be the first to weigh in.