Learning objectives
After reading this article you will be able to:
- Forward model tokens to the browser as server-sent events
- Read the stream on the client with fetch and render it live
- Fix proxy buffering, cancellation and reconnect problems
A large language model generates text one token at a time, and a long answer can take ten seconds or more to finish. If your backend waits for the whole reply before responding, the user stares at a spinner for that entire time. With streaming, the first words appear in a fraction of a second and the rest follow as they are generated.
The standard way to do this over HTTP is server-sent events (SSE). Below, you build a small Node.js endpoint that streams a model reply and a browser client that renders it live. Then you fix the problems that usually break streaming in production. For background on the model side, see What is a large language model (LLM)?.
What are server-sent events?#
SSE is a plain HTTP response that stays open until the server is done. The server sends Content-Type: text/event-stream and writes text messages as it goes. Each message is one or more field: value lines, followed by a blank line:
data: Hello
event: done
data: {}
The fields that matter:
data:is the payload. Consecutivedata:lines in one message are joined with a newline.event:names the event. Without it the browser treats the message as a defaultmessageevent.id:sets the last event ID, which the browser sends back asLast-Event-IDwhen it reconnects.retry:sets the reconnect delay in milliseconds.- A line starting with
:is a comment, handy as a keep-alive.
Because it is ordinary HTTP, SSE works with the normal tooling for auth, logging and TLS. Most LLM providers use SSE for their own streaming APIs, so their SDKs already give you a stream to forward.
SSE or WebSockets for AI responses?#
For a chat reply, SSE is usually enough. Data flows one way, from server to browser, and the user's next message is just another HTTP request. WebSockets add a two-way channel, but connection upgrades, heartbeats and reconnect logic become your job. Pick WebSockets when the client must send data continuously on the same connection, such as voice input. Read What is a WebSocket and how does it work? for that case.
How do you stream tokens from the server?#
Install Express and the Anthropic SDK (the same pattern works with any provider that streams):
npm install express @anthropic-ai/sdkCreate server.mjs. The API key and model come from environment variables, never from the code:
import express from "express";
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic(); // reads ANTHROPIC_API_KEY
const app = express();
app.use(express.json());
app.post("/api/chat", async (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no", // tells nginx not to buffer this response
});
const send = (event, data) =>
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
const stream = client.messages.stream({
model: process.env.ANTHROPIC_MODEL, // a model id from your provider's docs
max_tokens: 1024,
messages: req.body.messages,
});
// Stop paying for tokens nobody will read.
res.on("close", () => {
if (!res.writableEnded) stream.abort();
});
stream.on("text", (text) => send("token", text));
try {
await stream.finalMessage();
send("done", {});
} catch (err) {
if (!res.destroyed) send("error", { message: "Generation failed" });
}
res.end();
});
app.listen(process.env.PORT || 3000, "0.0.0.0");Run it with ANTHROPIC_API_KEY=... ANTHROPIC_MODEL=... node server.mjs. A few details are easy to miss:
JSON.stringifyon every chunk keeps newlines inside the text from breaking thedata:line format.no-transformstops intermediaries from compressing or rewriting the stream.- The
closehandler aborts the upstream request when the user closes the tab or presses stop. Without it, you keep generating (and paying for) a reply nobody sees. - Validate and limit
req.body.messagesin a real app, and authenticate the request before calling the model.
How do you read the stream in the browser?#
The built-in EventSource only sends GET requests, with no body and no custom headers, so it cannot carry a conversation or an auth token. Use fetch and read the body as a stream. The parser below splits on blank lines, which is how SSE messages end:
async function streamChat(messages, onToken, signal) {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
signal,
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let end;
while ((end = buffer.indexOf("\n\n")) !== -1) {
const raw = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
let event = "message";
let data = "";
for (const line of raw.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data += line.slice(5).trimStart();
}
if (event === "token") onToken(JSON.parse(data));
if (event === "error") throw new Error(JSON.parse(data).message);
if (event === "done") return;
}
}
}Call it with an AbortController, so a Stop button cancels the request and, through the server's close handler, the model call too:
const controller = new AbortController();
const out = document.querySelector("#answer");
streamChat(
[{ role: "user", content: "Explain SSE in two sentences." }],
(token) => (out.textContent += token),
controller.signal,
);
document.querySelector("#stop").onclick = () => controller.abort();Setting textContent rather than innerHTML matters: model output is untrusted text. If you render Markdown, sanitize the HTML first.
How do you check that streaming really works?#
Test the endpoint without a browser, using curl -N (the -N flag turns off curl's own buffering):
curl -N -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Count to ten."}]}'You should see event: token lines appear one after another, then event: done. If everything lands at once at the end, your code streams correctly but something along the way is buffering it. Repeat the test against the deployed URL: a stream that works locally and not in production almost always points to a proxy.
Why does the stream arrive all at once?#
These are the usual causes, from most to least common:
- Proxy buffering. nginx buffers responses by default. Set
proxy_buffering off;for the route, or keep theX-Accel-Buffering: noheader from the example. See What is a reverse proxy?. - Compression. Gzip middleware may hold chunks until it has enough to compress. Exclude
text/event-streamfrom compression. - Missing headers. Without
Content-Type: text/event-streamsome clients and proxies wait for the full body. - Idle timeouts. A proxy may close a connection that sends nothing for tens of seconds. Send a comment line such as
: pingfollowed by a blank line every 15 seconds while the model is thinking. - Browser connection limits. Over HTTP/1.1, browsers allow about six open SSE connections per domain across all tabs. HTTP/2 raises that to the negotiated stream limit, 100 by default.
If you use EventSource for a GET-only stream, it reconnects automatically after a drop and sends the last id: it saw as Last-Event-ID. A model call cannot resume mid-generation, so for chat the safer pattern is to show an error and let the user retry.
How to do this on Square Cloud#
A streaming endpoint holds each connection open for several seconds, and that is where a lot of hosting falls short: a buffering proxy delivers everything at the end, and a process that goes to sleep drops the request. On Square Cloud, the proxy in front of applications passes SSE through without buffering and does not cut long connections, and applications run in always-on containers with no cold start and no sleep mode.
To publish server.mjs on the Node.js runtime:
- Upload the project with its
package.json. There is no build step: at start, the platform runsnpm installwhennode_modulesis missing. - Pick
server.mjsas the main file. Thesquarecloud.appfile is optional: the dashboard generates it at deploy, or you write it at the project root. - Add
ANTHROPIC_API_KEYandANTHROPIC_MODELto the application's environment variables, through the dashboard, API, CLI or VS Code. A change applies on the next restart, without a rebuild.
The application gets a *.squareweb.app subdomain with HTTPS and listens on 0.0.0.0, on the port in the PORT variable, as the example already does. A web application uses at least 512 MB of the plan's RAM pool; Hobby starts at R$12.49 a month. After deploying, repeat the curl -N test against the subdomain while you watch the logs in real time: if the token events arrive one by one, the whole path works.
One caveat about the AI Gateway, the OpenAI-compatible API for the cubic model, in beta from the Standard plan: it does not stream yet, and a request with stream: true comes back with a 400 error. To show the answer token by token, as here, stay with a provider that streams. The gateway fits calls where the answer can arrive whole, such as background summaries and classification. For the full app around the endpoint, see how to host an AI chatbot and the API hosting page.
AI
Host AI apps in always-on containers
FAQs
What is the difference between SSE and WebSockets for AI streaming?
SSE is one-way (server to browser) over plain HTTP, which is all a chat reply needs. WebSockets are two-way and need their own connection handling, so they only pay off when the client must send data continuously on the same connection.
Why can't I use EventSource for a chat endpoint?
EventSource only makes GET requests with no body and no custom headers. A chat needs to send the conversation, usually with POST and an auth header, so use fetch and read the response body as a stream.
Why does my stream arrive all at once instead of token by token?
Something between your code and the browser is buffering the response. Common causes are a reverse proxy with buffering on, gzip compression of the stream, or a missing flush. Disable buffering for that route and test with curl -N.
Do I need to stream if the model is fast?
Long answers still take several seconds to finish. Streaming shows the first words almost immediately, which makes the app feel faster even though total time is the same.
More in AI
See all 13 articles- How much does an AI chatbot cost to build and run?Running an AI chatbot costs model tokens, hosting, a database and, with RAG, extra context per message. See how to estimate the cost per conversation.
- How much does a RAG system cost? Embeddings, database and LLMA RAG system has three costs: embeddings, a vector database and the LLM. See the math for 1,000 questions a day, with hypothetical prices, and where the bill grows.
- How to build a Discord AI botBuild a Discord AI bot with discord.js: an /ask command, the API key in an environment variable, per-channel memory, Discord limits and how to host it.
- How to host an AI chatbotAn AI chatbot is an API that takes messages, calls a language model and streams the reply back. Here is the code and how to put it online.