Skip to content
AI Beginner Tutorial

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.

Priya Nair
Priya Nair
AI & Developer Experience Writer · Jun 12, 2026 · 6 min read

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.

Advertisement

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 + ReadableStream rather than EventSource because EventSource only 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 to buffer so it is completed by the next chunk before being parsed. Without this, a JSON payload split across two network chunks causes JSON.parse to 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 appending undefined.

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:

  1. Open DevTools → Network tab
  2. Submit a prompt and click the /stream request
  3. 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-parser package is a battle-tested alternative to the manual buffer approach used here.
  • React / Next.js: The Vercel AI SDK wraps this pattern with useChat hooks and handles edge cases for you.
  • System prompts: Add a system: 'You are a helpful assistant.' field to messages.create() to give Claude a persona.
  • Model selection: Check the Anthropic models overview for the latest available model IDs.
Priya Nair
Written by
Priya Nair · AI & Developer Experience Writer

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

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading