Stream LLM Responses in a Web App
22 Dec 2025Streaming makes an LLM response feel instant instead of making the user stare at a spinner for eight seconds. The mechanics are simple. The edge cases are what break in production.
The full path, including the close-tab case:
sequenceDiagram
participant Browser
participant Server
participant LLM
Browser->>Server: GET /chat stream request
Server->>LLM: stream true
loop each token
LLM-->>Server: token chunk
Server-->>Browser: SSE data event
end
Browser--xServer: tab closed
Server->>LLM: abort upstream call
The server side: Server-Sent Events
SSE is the right transport for one-directional token streams — simpler than WebSockets, works over plain HTTP, and every browser has EventSource built in. A minimal Express endpoint proxying an OpenAI stream:
app.get('/chat', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
const stream = await client.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: req.query.prompt }],
stream: true,
})
for await (const chunk of stream) {
const token = chunk.choices[0]?.delta?.content || ''
if (token) res.write(`data: ${JSON.stringify({ token })}\n\n`)
}
res.write('data: [DONE]\n\n')
res.end()
})
The client side
const evtSource = new EventSource(`/chat?prompt=${encodeURIComponent(prompt)}`)
evtSource.onmessage = (e) => {
if (e.data === '[DONE]') { evtSource.close(); return }
const { token } = JSON.parse(e.data)
appendToOutput(token)
}
EventSource only supports GET requests with query params, no custom headers, no request body. That’s fine for a prompt in a query string, not fine if you need to send auth headers or a large payload. For those cases, use fetch with a ReadableStream reader instead — more code, but no transport limitation.
Backpressure: the part people skip
If the client is slow to consume — a throttled connection, a background tab — and your server keeps writing chunks as fast as the LLM produces them, Node buffers those writes in memory. On one connection this is nothing. On a few thousand concurrent streams to slow clients, it’s a real memory leak that only shows up under load you didn’t test with.
Respect res.write()’s return value. It returns false when the internal buffer is full:
const ok = res.write(`data: ${JSON.stringify({ token })}\n\n`)
if (!ok) {
await new Promise(resolve => res.once('drain', resolve))
}
This pauses your loop until the client’s buffer has room, instead of piling tokens up in server memory faster than the network can send them.
What to do when the user closes the tab
This is the case that actually costs you money if you skip it: the user navigates away mid-response, but if you don’t detect that, your server keeps calling the LLM API and burning tokens for a response nobody will ever see.
req.on('close', () => {
stream.controller?.abort()
})
Listen for the request’s close event and abort the upstream LLM call. Most SDK clients (OpenAI’s included) accept an AbortController signal on the request — wire it through so closing the tab actually stops billing, not just stops rendering.
Reconnection
Plain EventSource auto-reconnects on a dropped connection, but it starts the request over from scratch — there’s no built-in resume-from-token. If a flaky connection matters for your use case, track the last received token index server-side per request ID, and let the client send that index on reconnect so you can resume rather than restart the whole generation.
The one thing to test before shipping
Throttle your own connection (Chrome DevTools → Network → Slow 3G) and watch memory on the server while streaming. If it climbs and doesn’t come back down after the response ends, you’re missing the backpressure handling above, not something more exotic.