How to Stream Claude Responses in Node.js
Streaming Claude responses in Node means opening a request that stays connected while the model generates tokens, then reading and forwarding those tokens as they arrive instead of waiting for the full completion. The mechanism is Server-Sent Events (SSE): the server sends a sequence of event: / data: lines over a single HTTP response, and your Node process reads that response body as a stream, parsing each chunk as it lands.
This matters for anything with a chat UI, a CLI tool, or a voice interface — users perceive a token-by-token response as instant, even if total generation time is identical to a blocking call. Below is a working implementation using Node's built-in fetch, followed by the event types you need to handle correctly, and how the same code looks if you're calling Claude through an API layer like SubToAPI instead of managing raw provider streams yourself.
Prerequisites
- Node 18+ (native
fetchandReadableStreamsupport — nonode-fetchneeded) - An API key with access to a Claude-compatible messages endpoint
- Basic familiarity with async iterators or Node streams
You don't need a special SSE library. The response body from fetch is a ReadableStream, and you can decode it with TextDecoder and split on newlines manually. This is the same technique regardless of which provider or proxy sits behind the API.
Basic streaming request
Here's a minimal streaming call against a Messages-style API:
async function streamCompletion(prompt) {
const response = await fetch("https://your-api-host/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.API_KEY}`,
},
body: JSON.stringify({
model: "claude-sonnet-4",
max_tokens: 1024,
stream: true,
messages: [{ role: "user", content: prompt }],
}),
});
if (!response.ok || !response.body) {
throw new Error(`Request failed: ${response.status}`);
}
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(); // keep incomplete line in buffer
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
const event = JSON.parse(payload);
if (event.type === "content_block_delta") {
process.stdout.write(event.delta.text);
}
}
}
}
The critical detail most implementations get wrong is buffering: a single read() call from the network can return a partial line, or several events joined together. You must accumulate into a buffer, split on newlines, and hold back the last (possibly incomplete) fragment for the next chunk. Skipping this causes intermittent JSON.parse failures under real network conditions — it often works fine locally and breaks in production.
Handling stream event types
A well-formed SSE stream from a Claude-compatible API sends distinct event types, not just raw text deltas:
message_start— stream opened, contains initial message metadatacontent_block_start— a new content block (text or tool call) begancontent_block_delta— incremental text or tool-input fragmentscontent_block_stop— the current block finishedmessage_delta— usage and stop-reason updatesmessage_stop— stream complete
If you're building anything beyond a simple text echo — tool use, multi-block responses, token accounting — branch on event.type explicitly rather than assuming every event is a text delta:
switch (event.type) {
case "content_block_delta":
if (event.delta.type === "text_delta") {
process.stdout.write(event.delta.text);
}
break;
case "message_delta":
console.log("\nUsage:", event.usage);
break;
case "message_stop":
console.log("\nStream complete.");
break;
}
Piping the stream to an HTTP response
If you're relaying this to a browser client, pipe the same pattern through your own SSE endpoint instead of buffering the full response server-side:
app.post("/chat", async (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const upstream = await fetch("https://your-api-host/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.API_KEY}`,
},
body: JSON.stringify({ ...req.body, stream: true }),
});
upstream.body.pipe(res);
});
Piping the raw upstream body directly avoids re-serializing every chunk and keeps latency close to the theoretical minimum.
Streaming with SubToAPI
If you're already routing Claude calls through SubToAPI, streaming works the same way — the endpoint accepts stream: true and returns standard SSE, so the code above needs zero changes beyond the host and key:
curl https://api.subtoapi.app/v1/messages \
-H "Authorization: Bearer $SUBTOAPI_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-sonnet-4",
"max_tokens": 1024,
"stream": true,
"messages": [{"role": "user", "content": "Write a haiku about Node.js streams"}]
}'
The advantage is that you get application-scoped keys (sub_live_...), per-key usage metadata, and team seat management on top of the same streaming behavior, instead of building key rotation and usage logging yourself. Full event reference and edge cases are in /docs/streaming, and the general request/response shape is in /docs/messages. If you're setting this up for the first time, /docs/quickstart walks through generating a key and making your first call.
questions
Do I need a special SSE library for Node? No. Node 18+ has native fetch with a readable stream body, which is enough to parse SSE manually. Libraries like eventsource-parser help if you want stricter spec compliance, but a manual buffer-and-split loop is sufficient for most apps.
Why does my stream occasionally throw a JSON parse error? Almost always a buffering bug — a network chunk cut a line in the middle. Accumulate incoming text into a buffer, split on newlines, and only parse complete lines, keeping the trailing partial line for the next read.
Can I stream tool use output the same way? Yes, but tool inputs arrive as content_block_delta events with input_json_delta fragments that you must concatenate and parse once the block closes, rather than treating them as plain text. See /docs/tools for the exact event sequence.