← Blog

Claude API Response Streaming in Node Express

2026-10-16 · 4 min read · SubToAPI Team

Streaming Claude API responses in a Node Express app means forwarding tokens to the browser as they're generated, instead of waiting for the full completion. This matters because Claude responses can take several seconds for longer outputs, and a blank loading spinner feels broken while a steadily growing response feels fast — even if total latency is identical.

The short answer: you proxy the Claude streaming endpoint through an Express route using Server-Sent Events (SSE) or chunked transfer encoding, pipe the upstream stream directly to res, and consume it on the client with EventSource or a fetch reader. Below is a complete, working setup.

Why stream through your own backend

You could call Claude directly from the browser, but that exposes your API key client-side. The standard pattern is:

  1. Browser sends a prompt to your Express server.
  2. Express calls Claude (or a Claude-compatible proxy) with streaming enabled.
  3. Express forwards each chunk to the browser as it arrives.

This keeps your key on the server and lets you add logging, rate limiting, or usage tracking in the middle without buffering the whole response first.

Setting up the Express route

Here's a minimal streaming proxy using express and the official SDK pattern. If you're using Claude directly, the shape is the same whether you call Anthropic's API or a drop-in gateway like SubToAPI — the streaming contract (SSE with message_start, content_block_delta, message_stop events) is identical.

import express from "express";

const app = express();
app.use(express.json());

app.post("/api/chat", 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");
  res.flushHeaders();

  const upstream = await fetch("https://api.subtoapi.app/v1/messages", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.SUBTOAPI_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      model: "claude-sonnet-4-5",
      max_tokens: 1024,
      stream: true,
      messages: [{ role: "user", content: prompt }]
    })
  });

  const reader = upstream.body.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    res.write(decoder.decode(value));
  }

  res.end();
});

app.listen(3000);

The key detail is res.flushHeaders() before any writes — without it, some proxies and browsers buffer the response until the connection closes, which defeats the whole point of streaming.

Handling backpressure

Under load, res.write() can return false if the client's TCP buffer is full. If you ignore this, you'll pile up memory for slow clients. A safer write loop pauses the upstream reader until Express signals it's ready:

async function pipeStream(reader, res) {
  const decoder = new TextDecoder();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    const ok = res.write(decoder.decode(value));
    if (!ok) {
      await new Promise((resolve) => res.once("drain", resolve));
    }
  }
  res.end();
}

For typical chat traffic this rarely triggers, but it's worth having if you're streaming to many concurrent clients or serving large completions with max_tokens set high.

Parsing SSE events instead of raw text

The example above forwards raw bytes, which works for simple pass-through but doesn't let you inspect or transform events server-side. If you need to strip metadata, log token counts, or filter specific event types before sending to the client, parse the SSE frames:

function parseSSE(chunk, onEvent) {
  const lines = chunk.split("\n");
  for (const line of lines) {
    if (line.startsWith("data: ")) {
      const data = line.slice(6);
      if (data === "[DONE]") continue;
      try {
        onEvent(JSON.parse(data));
      } catch {
        // partial chunk, buffer and retry on next read
      }
    }
  }
}

In practice, buffer incomplete lines across chunk boundaries — SSE frames don't always arrive aligned with your read() calls.

Consuming the stream on the client

With Content-Type: text/event-stream set correctly, EventSource works, but it only supports GET requests. For POST-based chat endpoints, use fetch with a reader instead:

async function streamChat(prompt) {
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt })
  });

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let output = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    output += decoder.decode(value);
    renderPartial(output);
  }
}

renderPartial is just your UI update function — append the delta text to a DOM node or state variable, re-render, repeat.

A simpler option: let the API handle it

Writing and maintaining this proxy layer — SSE parsing, backpressure, reconnect logic, error passthrough — is one of the more tedious parts of building on top of Claude. SubToAPI exposes streaming through the same /v1/messages endpoint shown above, so your Express route code doesn't change; you just point it at a stable HTTPS API with application keys instead of managing SDK auth per environment. See the streaming docs and messages reference for the exact event format, or the quickstart to get a key running in a few minutes.

Common mistakes to avoid

Questions

Does Express support streaming responses natively? Yes. res.write() sends data immediately as long as headers are flushed first and you avoid middleware that buffers the full response, like some compression middleware configured incorrectly.

Can I use EventSource instead of fetch for the client side? Only for GET requests — EventSource doesn't support POST bodies or custom headers, so most chat UIs use fetch with a stream reader instead.

Why does my stream arrive all at once instead of incrementally? Usually a missing res.flushHeaders() call, a reverse proxy buffering the response, or Content-Encoding: gzip being applied to an SSE stream — disable compression on that route.

Turn your Claude access into an HTTPS API

SubToAPI gives you application API keys, streaming, tool use and usage insights on top of your existing Claude access — set up in minutes.

Start free  Read the quickstart →