Streaming Claude Responses to the Browser

Claude API & Tool Use with Angular and Node.js · The Messages API · 12 min read · Updated

Have you used a chat assistant where the words appear one after another while it is still writing? That is streaming. In this lesson you will add it to your AI Lab. It makes your app feel fast, even when the full answer takes ten seconds to write.

Why streaming matters

Without streaming, the user presses the button and stares at a spinner until the whole answer is ready. For a long answer this can feel like a broken app. With streaming, the first words appear in a second or two and the user starts reading right away. The total time is the same, but the waiting feels much shorter. Think of the difference between waiting for a full meal to be cooked before you get anything, and getting the first dish as soon as it is ready.

Streaming does not make the answer cheaper or smarter. The token cost is exactly the same. It only improves the experience.

Streaming with the SDK

The SDK makes streaming easy. Instead of messages.create, you call messages.stream. You can then listen for the "text" event, which fires every time a new piece of text arrives, and wait for the final complete message.

const stream = client.messages.stream({
  model: MODEL,
  max_tokens: 600,
  messages: [{ role: 'user', content: 'Write a short summary for a welder.' }],
});

stream.on('text', (piece) => process.stdout.write(piece));
const final = await stream.finalMessage();   // resolves when the answer is complete
console.log(final.usage);

Under the hood, the API sends many small events (the start of the message, pieces of text, the end with the final token counts). The SDK reads them for you and gives you friendly events.

Passing the stream to the browser: Server-Sent Events

Your server has the stream, but the browser is waiting for it. How do you pass the pieces along? The simple, popular way is Server-Sent Events, or SSE. It is a one-way stream from server to browser over a normal HTTP connection. The server sets a special content type, "text/event-stream", and then writes small messages in a simple format:

event: text
data: "Hello"

event: text
data: " world"

event: done
data: {"usage":{...}}

Each message has an event name and some data, and a blank line ends it. Our server sends three kinds: text (a piece of the answer), done (the answer is finished, with the usage) and error (something went wrong).

A few details make streaming work properly on a real server.

  • Send the header X-Accel-Buffering: no. Some web servers, such as nginx, hold back the response to send it in big chunks. This header tells nginx not to, so the words arrive immediately.
  • Call res.flushHeaders() so the browser knows the stream has started.
  • Listen for the response closing. If the user closes the page or presses Stop, call stream.abort(). This stops the request to Claude, and you stop paying for words nobody will read.
  • Handle errors that happen in the middle. You have already sent a 200 status, so you cannot change it. Send an "error" event instead.

Reading the stream in Angular

The built-in HttpClient is not the best tool for streaming. For this job we use the browser's fetch function, which gives us a body we can read piece by piece. The idea has four steps.

  1. Send the request with fetch and an AbortController, so we can cancel it.
  2. Get a reader from response.body.getReader().
  3. Loop: read a chunk of bytes, turn it into text with a TextDecoder, and add it to a buffer.
  4. Split the buffer on blank lines. Each complete part is one event. Keep the incomplete rest in the buffer for the next round.

The last step is the tricky one. A chunk of data can end in the middle of an event, so you must never assume that one chunk equals one event. That is why we keep a buffer and only process complete events.

For each event, we look at its name. A text event adds the piece to the output signal, and because the signal changes, Angular redraws the text each time. A done event shows the final usage. An error event shows a message.

The Stop button

A good streaming screen lets the user stop. In Angular we keep the AbortController, and the Stop button calls controller.abort(). The fetch is cancelled, the browser closes the connection, and our server notices the closed response and aborts the call to Claude. Users like this control, and it saves tokens. Remember to treat the resulting AbortError as normal, not as an error to show.

Streaming and structured results

Streaming is perfect for text that a person reads. It is not a good fit when your code needs a complete JSON result, because you cannot use half of a JSON object. In that case, either do not stream, or stream to the screen and validate the final complete answer when the stream ends.

Your task: build the Stream page

Build a page where the answer appears word by word, with Start and Stop buttons.

What you will build

  • A POST /api/chat/stream route that sends Server-Sent Events: text, done and error.
  • A Stream page in Angular that reads the stream with fetch and shows the text as it grows.
  • A Stop button that cancels both the browser request and the call to Claude.

Steps

  1. Start from your lesson 4 project, or download the lesson 4 solution.
  2. Create server/routes/stream.js. Reuse cleanMessages(). Set the SSE headers and call flushHeaders().
  3. Create the stream with client.messages.stream, forward each "text" event, and send "done" with usage at the end.
  4. Add res.on("close") to abort the stream if the response is closed before it finished.
  5. In Angular, build the page with signals for output, running, error and info. Write the reading loop with a buffer.
  6. Test it: press Start and watch the words arrive. Press Start again and press Stop in the middle. Check the server log or the usage in your console to confirm that the request stopped.

Hints

  • Split the buffer with buffer.split("\n\n") and keep the last piece.
  • Parse an event with two small regular expressions: one for the line that starts with "event: " and one for "data: ".
  • The data is JSON, so use JSON.parse. Text pieces are JSON strings, so spaces at the start of a piece are kept.

You know it works when words appear gradually, Stop ends the answer at once, and an error in the middle shows a message instead of breaking the page.

Solution

The server route:

import { Router } from 'express';
import { client, MODEL } from '../claude.js';
import { cleanMessages, lastIsUser } from '../messages.js';

const router = Router();

// POST /api/chat/stream  -> Server-Sent Events: "text" chunks, then "done" (or "error")
router.post('/chat/stream', async (req, res) => {
  const system = String(req.body?.system || '').slice(0, 2000);
  const messages = cleanMessages(req.body?.messages);
  if (!lastIsUser(messages)) {
    return res.status(400).json({ error: { message: 'Send at least one message from the user.' } });
  }

  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no'); // tells nginx not to hold the text back
  res.flushHeaders();

  const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);

  const stream = client.messages.stream({
    model: MODEL,
    max_tokens: 600,
    ...(system ? { system } : {}),
    messages,
  });

  // If the user closes the page or presses Stop, stop paying for tokens nobody reads.
  let aborted = false;
  res.on('close', () => {
    if (!res.writableEnded) {
      aborted = true;
      stream.abort();
    }
  });

  stream.on('text', (chunk) => send('text', chunk));

  try {
    const final = await stream.finalMessage();
    send('done', { usage: final.usage, stopReason: final.stop_reason });
  } catch (e) {
    if (!aborted) {
      console.error('[stream]', e?.message);
      send('error', { message: 'The reply was interrupted. Please try again.' });
    }
  } finally {
    res.end();
  }
});

export default router;

The Angular page:

import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-stream',
  imports: [FormsModule],
  template: `
    <h1>Streaming reply</h1>
    <p class="hint">Words appear as Claude writes them. Press Stop to cancel; the server stops the request too.</p>

    <div class="card">
      <label for="p">Your prompt</label>
      <textarea id="p" [(ngModel)]="prompt"></textarea>
      <button (click)="start()" [disabled]="running() || !prompt.trim()">Start</button>
      <button class="secondary" (click)="stop()" [disabled]="!running()">Stop</button>
      @if (error()) { <div class="err">{{ error() }}</div> }
      <div class="answer">{{ output() }}@if (running()) { <span class="muted"> ▍</span> }</div>
      @if (info()) { <p class="muted">{{ info() }}</p> }
    </div>
  `,
})
export class StreamPage {
  prompt = 'Write a short professional summary for a welder with 4 years of experience.';
  output = signal('');
  running = signal(false);
  error = signal('');
  info = signal('');
  private controller: AbortController | null = null;

  async start() {
    this.output.set('');
    this.error.set('');
    this.info.set('');
    this.running.set(true);
    this.controller = new AbortController();

    try {
      const response = await fetch('/api/chat/stream', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ messages: [{ role: 'user', content: this.prompt }] }),
        signal: this.controller.signal,
      });
      if (!response.ok || !response.body) throw new Error('bad response');

      // Read the stream piece by piece. Events are separated by a blank line.
      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';
      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });
        const events = buffer.split('\n\n');
        buffer = events.pop() ?? '';
        for (const raw of events) this.handle(raw);
      }
    } catch (e) {
      if ((e as Error).name !== 'AbortError') this.error.set('Could not stream the reply. Please try again.');
    } finally {
      this.running.set(false);
      this.controller = null;
    }
  }

  private handle(raw: string) {
    const event = /^event: (.*)$/m.exec(raw)?.[1];
    const data = /^data: (.*)$/m.exec(raw)?.[1];
    if (!event || data === undefined) return;
    const value = JSON.parse(data);
    if (event === 'text') this.output.update((t) => t + value);
    if (event === 'done') this.info.set(`Done. ${value.usage.output_tokens} output tokens.`);
    if (event === 'error') this.error.set(value.message);
  }

  stop() {
    this.controller?.abort();
    this.info.set('Stopped.');
  }
}

Download the lesson 5 solution (zip)

What you learned

  • Streaming shows words as they are written. It improves the feeling of speed, not the cost.
  • Server-Sent Events send text, done and error messages from server to browser.
  • In Angular, read the stream with fetch, keep a buffer, and split on blank lines.
  • Always let the user stop, and abort the Claude request when the response closes.

Lesson 6 makes your app reliable when things go wrong: errors, retries and timeouts.

Frequently asked questions

Does streaming make the answer cheaper?

No. The token cost is the same. Streaming only improves how fast the first words appear.

Why does my streamed answer arrive all at once?

A proxy or compression layer is probably buffering it. Turn off buffering for that route, for example with the X-Accel-Buffering header for nginx.

Can I use Angular HttpClient for streaming?

It is possible with special options, but the browser fetch function with a stream reader is simpler and gives you direct control, including cancelling.