Claude API Response Streaming in Node Express
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:
- Browser sends a prompt to your Express server.
- Express calls Claude (or a Claude-compatible proxy) with streaming enabled.
- 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
- Buffering with
res.send()instead ofres.write()—send()ends the response immediately, which breaks streaming entirely. - Forgetting
Connection: keep-alive— some reverse proxies (notably older Nginx configs) will buffer without it. - Not handling client disconnects — listen for
req.on("close", ...)and abort the upstream fetch to avoid wasting tokens on an abandoned request. - Mixing
express.json()with streaming responses on the same route — this is fine for the request body, but double-check you're not accidentally buffering the response too.
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.