Stream LLM Responses to a React Frontend Without Melting
For: A mid-level full-stack engineer at a seed-stage SaaS startup who has a working ChatGPT-style feature that waits for the full LLM response before rendering — users are complaining it feels broken during the 4–8 second silence — and every tutorial they find either uses a third-party SDK abstraction that hides how streaming actually works or breaks when they try to add auth headers and parse partial JSON safely
If your LLM feature blocks for 4–8 seconds before rendering, you are waiting on the full completion instead of streaming. The fix is a Node proxy that forwards OpenAI's stream: true response as Server-Sent Events, plus a React consumer that reads fetch's ReadableStream as a state machine with explicit done, error, and abort transitions. The tutorial below is the minimum runnable version that survives auth headers, mid-stream failures, and slow 4G.
Most tutorials skip the parts that actually break in production: what happens when the socket dies at token 47, how to inject a bearer token (EventSource cannot), and how to parse structured JSON that arrives split across chunks. We will handle all three.
Why your current implementation feels broken
You almost certainly have this pattern somewhere:
const res = await fetch('/api/chat', { method: 'POST', body });
const { text } = await res.json();
setMessage(text);The server is waiting on OpenAI to finish generating. OpenAI is generating at ~30–80 tokens/sec. A 400-token answer means multiple seconds of silence. Users assume it is broken and click again, which is how you get duplicate requests and duplicate charges.
The insight most tutorials miss: opening a stream is easy. Consuming it correctly under real network conditions is not. EventSource looks simpler than fetch + ReadableStream, until you need to send a POST body or an Authorization header — neither of which EventSource supports. And a naive for await (const chunk of reader) loop will silently swallow errors, double-append tokens on retry, or leak the connection if the component unmounts mid-stream.
Prerequisites
- Node 18+ (for native
fetchandReadableStream) - React 18+ (any bundler — Vite, Next.js, CRA)
- An OpenAI API key in
process.env.OPENAI_API_KEY - Basic familiarity with async iterators and
AbortController
Step 1: Set up the Node proxy
Never call OpenAI from the browser — your key will get scraped in hours. Create a tiny Express server:
mkdir llm-stream && cd llm-stream
npm init -y
npm i express cors
node --version # should be 18+Create server.js:
import express from 'express';
import cors from 'cors';
const app = express();
app.use(cors());
app.use(express.json());
app.post('/api/chat', async (req, res) => {
const { messages } = req.body;
const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages,
stream: true,
}),
});
if (!upstream.ok) {
const err = await upstream.text();
return res.status(upstream.status).json({ error: err });
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // disable nginx buffering
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
req.on('close', () => reader.cancel());
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(decoder.decode(value, { stream: true }));
}
res.end();
});
app.listen(3001, () => console.log('Proxy on :3001'));Note three things: X-Accel-Buffering: no disables nginx's default buffering (this alone has burned a lot of teams), req.on('close') propagates client disconnects upstream so you stop paying for tokens the user will never see, and we forward OpenAI's SSE format as-is instead of re-parsing on the server.
Expected output: Proxy on :3001. If you curl -N -X POST http://localhost:3001/api/chat -H 'Content-Type: application/json' -d '{"messages":[{"role":"user","content":"count to 5"}]}', you should see data: {...} lines streaming in one at a time, ending with data: [DONE].
Step 2: Understand what you are consuming
OpenAI streams SSE frames that look like this:
data: {"choices":[{"delta":{"content":"Hello"}}]}
data: {"choices":[{"delta":{"content":" world"}}]}
data: [DONE]Three edge cases that will bite you:
- A single
reader.read()may return multiple events glued together, or half an event. You must buffer. - Empty
deltaobjects appear (e.g., the first frame with role info). Guard againstundefined. - The final
[DONE]sentinel is not JSON. Do not try toJSON.parseit.
Step 3: The React hook — state machine, not a loop
Here is the consumer. It uses fetch (not EventSource) so we can send POST bodies and auth headers:
// useLLMStream.js
import { useState, useRef, useCallback } from 'react';
type Status = 'idle' | 'streaming' | 'done' | 'error' | 'aborted';
export function useLLMStream() {
const [text, setText] = useState('');
const [status, setStatus] = useState<Status>('idle');
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const send = useCallback(async (messages, authToken) => {
// Cancel any in-flight stream before starting a new one
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setText('');
setError(null);
setStatus('streaming');
try {
const res = await fetch('http://localhost:3001/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({ messages }),
signal: controller.signal,
});
if (!res.ok) {
const body = await res.text();
throw new Error(`HTTP ${res.status}: ${body}`);
}
if (!res.body) throw new Error('No response body');
const reader = res.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 partial line for next chunk
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const payload = trimmed.slice(5).trim();
if (payload === '[DONE]') {
setStatus('done');
return;
}
try {
const json = JSON.parse(payload);
const delta = json.choices?.[0]?.delta?.content;
if (delta) setText(prev => prev + delta);
} catch {
// Partial JSON — safe to skip, next chunk will complete it
}
}
}
setStatus('done');
} catch (err) {
if (err.name === 'AbortError') {
setStatus('aborted');
} else {
setError(err.message);
setStatus('error');
}
}
}, []);
const abort = useCallback(() => abortRef.current?.abort(), []);
return { text, status, error, send, abort };
}The important bits:
- Buffer partial lines.
buffer.split('\n').pop()retains any incomplete line for the next iteration. This is what a naivefor awaitloop misses. - Silently skip parse errors mid-stream. A malformed
data:line usually just means the JSON was split — the next chunk completes it. Do not surface these as errors. - Abort on new send. If a user submits a second prompt while the first is still streaming, cancel the first. Otherwise tokens interleave.
- Distinguish
abortedfromerror. Aborts are user intent, not failures. Do not show a red banner for them.
Step 4: The component
function Chat() {
const { text, status, error, send, abort } = useLLMStream();
const [input, setInput] = useState('');
return (
<div>
<textarea value={input} onChange={e => setInput(e.target.value)} />
<button
onClick={() => send([{ role: 'user', content: input }], 'YOUR_JWT')}
disabled={status === 'streaming'}
>Send</button>
{status === 'streaming' && <button onClick={abort}>Stop</button>}
<pre>{text}</pre>
{status === 'streaming' && <span>▋</span>}
{status === 'error' && <div style={{color:'red'}}>{error}</div>}
</div>
);
}Expected output: Type a prompt, click Send. Tokens appear within ~200–400ms and stream in continuously. The Stop button aborts cleanly; no error banner appears.
Step 5: Handle structured JSON responses
Plain text streaming is the easy case. If you are asking the model for JSON (function calling, structured output), the JSON itself arrives in fragments. You cannot JSON.parse partial JSON.
Two workable approaches:
- Accumulate then parse. Buffer the full
contentstring, only parse on[DONE]. Simple. Users see nothing until completion — defeats streaming for JSON. - Incremental parsing with a permissive parser. Libraries like partial-json can parse
{"name": "Aliand return{ name: 'Ali' }. Good for rendering as fields arrive.
import { parse, Allow } from 'partial-json';
let accumulated = '';
// inside your delta handler:
accumulated += delta;
try {
const partial = parse(accumulated, Allow.ALL);
setParsedObject(partial); // render fields that exist so far
} catch {
// not enough yet
}Step 6: Backpressure and the double-render trap
On slow networks (throttled 4G in DevTools is a good test), chunks arrive irregularly. Two failure modes to watch for:
- Double tokens on retry. If you implement a retry-on-error, and the first attempt got 40 tokens through before failing, do not re-render those 40 tokens. Track a
completionIdper stream and resettexton retry. - React batching under fast streams. On a fast connection, you may get 20
setTextcalls in one frame. React 18's automatic batching handles this well, but if you are doing expensive work on each token (syntax highlighting, markdown parsing), debounce or userequestAnimationFrame.
Step 7: Deploy behind a proxy that does not buffer
The number one production surprise: your local setup works, you deploy behind nginx or Cloudflare, and streaming turns back into blocking. Fixes:
- nginx:
proxy_buffering off;andproxy_cache off;on the streaming route. - Cloudflare: streaming works, but the free tier has a 100-second response timeout. For longer completions, use Cloudflare Workers or bypass the proxy for the streaming route.
- Vercel / Netlify: use their Edge Functions or streaming-capable serverless runtimes. Traditional Lambda-style functions may buffer the full response before returning.
- AWS API Gateway: does not support streaming for REST APIs. Use a Lambda Function URL with
RESPONSE_STREAMinvoke mode, or ALB.
Common errors
Response arrives all at once instead of streaming
A proxy is buffering. Check for proxy_buffering, Cloudflare, or a CDN in front of your API. Test directly with curl -N against your origin — if that streams and the browser doesn't, the culprit is between them.
SyntaxError: Unexpected end of JSON input
You are calling JSON.parse on a partial line. Wrap the parse in a try/catch and skip on failure. The next chunk will complete the JSON.
Tokens double up after network hiccup
You have retry logic that does not reset the accumulated text. On retry, either start fresh or resume from a known offset — never both.
EventSource does not work with my auth
Correct. EventSource cannot send custom headers or POST bodies. Use fetch + ReadableStream as shown above. This is why the tutorial does not use EventSource.
Stream stalls on the first token in production
Likely gzip/brotli compression buffering the response. Disable compression on the streaming route, or ensure your proxy is set to flush on write.
Component unmounts but the request keeps running
You are not calling abort() in a cleanup. Add useEffect(() => () => abort(), [abort]) in the parent, or track the controller in a ref and abort on unmount.
What this approach is bad at
Honest tradeoffs:
- Reconnection. If the socket drops at token 200 of 400, this implementation gives up. Truly resilient streaming needs server-side session state and a resume protocol — significantly more work.
- Multi-consumer fan-out. One user, one stream. If you want to broadcast one LLM response to multiple viewers (collaborative apps), you need a pub/sub layer (Redis, Ably, Pusher).
- Token accounting under abort. When a user hits Stop, you have already paid for tokens generated up to that point. Log the partial completion for billing accuracy.
- Rate limiting. A streaming endpoint held open for 30 seconds counts differently than a fast POST. Adjust your rate limiter's concurrency budget accordingly.
For most seed-stage SaaS teams building a ChatGPT-style feature, none of these are day-one problems. Ship the state-machine consumer, disable proxy buffering, and revisit reconnection when you have paying users complaining about it. Teams working on more involved LLM product surfaces — RAG pipelines, agent workflows, multi-tenant inference — usually end up needing a purpose-built streaming layer, which is the kind of work we tackle in our AI studio.
Frequently Asked Questions
Should I use Server-Sent Events or WebSockets for LLM streaming?
SSE for anything one-directional (server pushes tokens to client). It runs over plain HTTP, works through most proxies, and reconnects on its own if you use the EventSource API. WebSockets only make sense if you also need the client pushing structured messages mid-stream — for a chat UI, you do not.
Why not just use the OpenAI Node SDK's streaming helper?
The SDK is fine for the server-to-OpenAI hop. The problem is that many tutorials pipe the SDK's async iterator directly into a WebSocket or SSE handler without buffering, and it breaks the moment you add middleware, auth, or a CDN. Owning the byte-level forwarding as shown above makes failure modes visible instead of magical.
How do I handle authentication for the streaming endpoint?
Send a bearer token in the Authorization header on the fetch call — this is exactly why we avoid EventSource, which cannot send custom headers. Validate the token in your Node proxy before opening the upstream connection to OpenAI. For session tokens that rotate, refresh before the stream starts, not during.
Can I stream from OpenAI directly in a Next.js API route?
Yes, but only in an Edge Runtime route or a Node runtime route that returns a Response with a ReadableStream body. The default serverless runtime on some platforms buffers the response. Check your platform's docs for "streaming responses" specifically — it is not the default everywhere.
How much does streaming cost extra compared to non-streaming?
Token usage is identical. What changes is connection duration and infrastructure behavior (open sockets, proxy timeouts, serverless invocation billing). For a specific assessment of streaming architecture for your product, talk to CodeNicely for a personalized review.
Found this useful? CodeNicely publishes engineering and product playbooks weekly. Browse the archive or tell us what you're building.
_1751731246795-BygAaJJK.png)