A streaming chat UI in React reads a Server-Sent Events response with fetch plus a ReadableStream reader, decodes each chunk with TextDecoder, splits complete data: frames, and appends token deltas to the last assistant message in state. Use fetch, not EventSource: chat completions need POST, a JSON body, and an Authorization header.
That is the whole mechanism. The rest of this guide covers what breaks in production — mid-frame read boundaries, multibyte characters split across chunks, markdown that is syntactically incomplete on every render, and scroll position that fights the user. This is the frontend half; the server half lives in our AI API streaming guide.
Why stream in the UI at all
Streaming does not make the model faster. Wall-clock time is unchanged, cost is unchanged, and the model produces the same tokens. All streaming does is move the first useful pixel from the end of generation to the beginning — from roughly four seconds to roughly three hundred milliseconds.
That change is worth more than any other frontend optimization available, because time-to-first-token (TTFT) is the only latency number a user perceives. A forty-second answer that starts painting in 300ms feels faster than a six-second answer that appears all at once — and for long answers it genuinely is faster, because the user reads while the model writes. Streaming converts dead waiting time into reading time.
- The first token must paint in the frame it arrives. Any queue, debounce, animation, or “wait for the full response then reveal” step cancels the benefit. Debouncing is right for markdown parsing (below) and wrong for appending text.
- You are rendering text you cannot take back. A model that goes wrong in sentence one has already painted sentence one. You cannot validate before render, so you validate after and make correction cheap — which is where structured outputs earn their place for anything machine-read.
- The naive implementation is O(n²). A 4,000-token answer at 80 tokens per second is 320 state updates. If each one re-parses the whole markdown document or re-highlights the whole code block, the UI gets slower the longer it runs.
Decision criterion: stream every response a human is waiting on and will read. Do not stream classification, extraction, or embeddings — buffering is less code, and no one is watching.
Consuming SSE in the browser: EventSource vs fetch + ReadableStream
Both APIs can consume text/event-stream. The choice is decided by three constraints, not by preference: HTTP method, request headers, and reconnect behavior.
| Constraint | EventSource | fetch + ReadableStream |
|---|---|---|
| HTTP method | GET only | Any — POST for chat completions |
| Request body | Not supported | Full JSON body, including the message array |
| Custom headers | Not supported (no Authorization) | Full control |
| Auth options | Cookies, or a key in the query string | Authorization: Bearer … |
| Auto-reconnect | Yes, built in, with Last-Event-ID | No — you implement it |
| Frame parsing | Done for you via onmessage | You split data: frames |
| Cancellation | .close() | AbortController |
| Best fit | GET-based, cookie-auth push feeds | OpenAI-compatible chat completions |
The rule: if the request is a GET with no body and cookie auth, EventSource is less code and reconnection is free. The moment you need POST, a body, or a bearer token — every chat completion — use fetch.
The CORS and auth caveat that rules out EventSource for chat
EventSource cannot set request headers. That leaves two ways to authenticate it, both unacceptable for a provider key:
- Key in the query string. It lands in server logs, browser history, and any
Refererheader. A credential in a URL is a credential you have to rotate. - Cookie auth. Requires
credentials: 'include'and a server echoing an exactAccess-Control-Allow-Origin— wildcards are forbidden once credentials are involved.
There is a quieter trap too. EventSource auto-reconnects on any connection close, including a normal one. Pointed at a billed completion endpoint, the browser can silently re-run the request and bill you twice for an answer you already received. With fetch, a closed stream is just a closed stream.
Before you debug React: fetch gives text/event-stream no special behavior — you receive an opaque byte stream and own the framing.
A minimal React hook for streaming
This hook runs as written. It sends the conversation, streams the reply, appends deltas to the last assistant message, and exposes a stop() that aborts cleanly.
import { useCallback, useRef, useState } from "react";
type Msg = { role: "user" | "assistant"; content: string };
export function useChatStream(
endpoint: string, // your own /api/chat route — never a provider key in the browser
token: string, // short-lived session token for your route
model = "gpt-4o-mini"
) {
const [messages, setMessages] = useState<Msg[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const bufRef = useRef(""); // carries a partial SSE frame across reads
const stop = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setIsStreaming(false);
}, []);
const send = useCallback(
async (text: string) => {
const history: Msg[] = [...messages, { role: "user", content: text }];
setMessages([...history, { role: "assistant", content: "" }]);
setError(null);
setIsStreaming(true);
bufRef.current = "";
const ac = new AbortController();
abortRef.current = ac;
// Touch only the last message: O(1) per token, not O(n).
const appendDelta = (delta: string) =>
setMessages((prev) => {
const next = prev.slice();
const last = next[next.length - 1];
next[next.length - 1] = { ...last, content: last.content + delta };
return next;
});
try {
const res = await fetch(endpoint, {
method: "POST",
signal: ac.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ model, messages: history, stream: true }),
});
if (!res.ok || !res.body) {
throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
// stream: true keeps a split multibyte character in the decoder.
bufRef.current += decoder.decode(value, { stream: true });
const frames = bufRef.current.split("\n\n");
bufRef.current = frames.pop() ?? ""; // keep the incomplete tail
for (const frame of frames) {
for (const line of frame.split("\n")) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
try {
const json = JSON.parse(payload);
const delta = json.choices?.[0]?.delta?.content;
if (delta) appendDelta(delta);
} catch {
// Half a JSON object: wait for the next read.
}
}
}
}
} catch (e) {
// An aborted fetch rejects — that is not an error to show anyone.
if ((e as Error).name !== "AbortError") setError((e as Error).message);
} finally {
setIsStreaming(false);
abortRef.current = null;
}
},
[endpoint, token, model, messages]
);
return { messages, send, stop, isStreaming, error };
}
Four details carry correctness:
bufRefexists because read boundaries are arbitrary. Oneread()can return half a frame, three frames, or one frame split mid-JSON-string. Splitting on\n\nand popping the last element back into the buffer keeps the final chunk of every response from being dropped.decoder.decode(value, { stream: true })is mandatory. Without the streaming flag, a multibyte character split across two reads — any emoji, any CJK text — decodes into replacement characters. Invisible in English-only testing; it appears the week you add a non-English user.- Replace one array element, not the whole history. Swapping the last message is constant work per token; rebuilding history with
map()makes a long conversation quadratic. - Swallow
AbortError. Aborting a fetch rejects the promise by design. Surfacing that turns a deliberate cancel into a red error banner.
Two caveats. send closes over messages, so its identity changes every token — if you pass it into a memoized child, move history into a ref or a reducer. And at 80 tokens per second you get 80 renders per second; React 18 only batches updates in the same tick, and each network read is its own tick. Throttle the render, never the append.
Rendering markdown as it arrives
The hard part is not markdown — it is that at every instant you parse a string that is syntactically incomplete. A fence opened three tokens ago has no closing fence. A link reads [docs](https://exa. A table has one column and no separator row.
- Never repair the source string. Do not append synthetic closing fences or
**markers to make the text parse — you will fight the parser, and the repair flickers as real tokens arrive. CommonMark already defines the right behavior: an unclosed fence is a code block running to end of input, an unclosed emphasis run stays literal. That is the preview you want, and it resolves on the next token. - Sanitize the output, and prefer never producing HTML.
react-markdownbuilds React elements and escapes embedded HTML unless you opt in withrehype-raw— and the moment you addrehype-raw,rehype-sanitizestops being optional, because it strips<script>,onerror=, andjavascript:URLs. If you inject an HTML string instead, sanitize after markdown conversion, with an allowlist. - Do not highlight code per token. A highlighter re-parses the whole block on every delta, so a 200-line code answer costs quadratic highlighting for text nobody can read yet. Render plain monospace while the fence is open, then highlight once when it closes.
- Suppress interactive elements while incomplete. A half-typed URL should not be a live anchor — a user can and will click it mid-stream. Render links as plain text until the message is done.
- Throttle the parse, not the append. Parsing 16 KB of markdown 80 times a second is wasted work; the eye cannot read faster than roughly 15 updates per second anyway. A 50ms throttle is imperceptible and removes most of the cost.
import { memo, useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeSanitize from "rehype-sanitize";
/** Coalesce rapid updates. On completion, render the final text immediately. */
function useThrottled(value: string, ms: number) {
const [v, setV] = useState(value);
useEffect(() => {
if (ms === 0) return setV(value);
const id = setTimeout(() => setV(value), ms);
return () => clearTimeout(id);
}, [value, ms]);
return v;
}
export const StreamingMarkdown = memo(function StreamingMarkdown({
text,
done,
}: {
text: string;
done: boolean;
}) {
// ~20fps while streaming; unthrottled on the final frame.
const shown = useThrottled(text, done ? 0 : 50);
return (
<div className="prose">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}
components={{
// No live links until the URL has fully arrived.
a: ({ href, children }) =>
done ? (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
) : (
<span className="link-pending">{children}</span>
),
}}
>
{shown}
</ReactMarkdown>
</div>
);
});
Abort, cancel, and regenerate
One AbortController per in-flight request, held in a ref. That one ref gives you cancel, regenerate, unmount cleanup, and timeout — they are all the same operation.
- One button, two states. While
isStreaming, the send button becomes Stop — an obvious cancel affordance that makes a second concurrent completion on the same thread impossible. - Keep the partial text on abort. Rolling the bubble back to empty is worse than leaving the truncated answer: the user already read it, it is already billed, and they may want to copy it. Mark it stopped; do not delete it.
- Aborting closes the client socket — upstream is not guaranteed to stop. A relay that ignores the disconnect keeps generating and you keep paying for tokens nobody sees. Treat tokens already emitted as billed.
- Regenerate is drop-and-resend. Remove the last assistant message and re-send the same history through the same code path. Keep the previous answer behind a “show previous” toggle — users frequently prefer the first draft.
- Abort on unmount. Add the abort call to a
useEffectcleanup. Without it, a stream keeps writing into a component that no longer exists, and React 18 no longer warns you. - Add a deadline. A stream that never sends its terminator leaves the Stop button spinning forever. Where supported, combine signals with
AbortSignal.any([ac.signal, AbortSignal.timeout(60_000)]); otherwise asetTimeoutthat callsac.abort()is enough.
UX details that decide whether it feels good
Small things, individually invisible, collectively the difference between a demo and a product.
| State | Trigger | What the user sees | What must not happen |
|---|---|---|---|
| Sending | send() called | User bubble appears instantly | Textarea still holding the text |
| Thinking | Request open, zero tokens | Spinner after a ~400ms delay | Spinner flashing on and off |
| Streaming | First delta appended | Text plus a blinking caret | Scroll hijack while reading |
| Stopped | abort() | Truncated text marked “Stopped” | Bubble cleared |
| Error | Non-2xx or network failure | Error card inside the bubble + Retry | The user’s message lost |
| Done | [DONE] or reader closed | Caret removed, actions revealed | Links live before completion |
Auto-scroll, correctly. The most common bug in streaming chat UIs is calling scrollIntoView() on every token. It yanks the viewport back down the moment a user scrolls up to re-read. Auto-scroll only when the user is already at the bottom — measure scrollHeight - scrollTop - clientHeight < 48 — and otherwise show a “Jump to latest” pill. Someone scrolled up during generation is reading; respect it.
Optimistic user bubble. Append the user’s message and clear the textarea in the same handler, before the await fetch. Waiting for a round trip to render the user’s own text adds perceived latency for zero information.
Three visual states, not two. “Thinking” and “streaming” should look different on screen; collapsing them is why many chat UIs feel broken for the first second after you hit send.
Delay the spinner. If TTFT is under ~1.5s, a spinner that appears and vanishes reads as jank. Show it after a 300–500ms delay and cancel the timer if the first token beats it. A blinking caret at the end of the streaming text is cheaper and higher-signal than any animation.
Keep the composer enabled. Disable only Send, never the textarea. Letting users draft the next message while the model writes is free perceived speed. Give the streaming bubble a min-height so the scrollbar does not jump when the first line lands.
Do not put aria-live on the streaming node. Announcing every token floods the screen reader queue and produces gibberish. Leave the visible stream unannounced and mirror the final text into a visually hidden aria-live="polite" region when done flips true.
The backend contract your UI depends on
The hook above assumes a specific contract. Get these six things right and the frontend needs no special cases; the wire format itself is documented in our AI API streaming guide.
- POST, with
"stream": truein the body. Not a GET with a query parameter. Content-Type: text/event-stream, with buffering off. Behind nginx that meansX-Accel-Buffering: no; behind Cloudflare, buffering disabled for the route. If tokens all arrive together, this is the cause far more often than your React code.- One JSON object per
data:line, OpenAI-compatible. Text arrives atchoices[0].delta.content; the last chunk carriesfinish_reason. - A terminal
data: [DONE]. Treat a reader that closes without it as a truncation — otherwise a dropped connection looks like a complete answer. - CORS, if you call it directly from the browser. An exact
Access-Control-Allow-OriginandAccess-Control-Allow-Headers: Authorization, Content-Type. Wildcards break the moment credentials are involved. - Never ship a provider key in a browser bundle. Anything in a JS bundle is public. Put a thin route in front: your app streams from your own
/api/chat, which holds the key server-side.
That server route can point at a single OpenAI-compatible endpoint. qoraapi.com is an AI API relay exposing many models — GPT, Claude, Gemini, open-weight — behind one base URL and one key, with stream: true on the same path, so switching models is a string in your request body rather than a change to the hook. If the same endpoint must also return schema-valid JSON, that is what structured outputs handle; if you are still assembling the request layer, start from our walkthrough on how to build an AI chatbot with the API.
Frequently asked questions
Can I use EventSource for OpenAI-compatible chat streaming?
No. Chat completions need a POST with a JSON body and an Authorization header; EventSource is GET-only and cannot set headers. It also auto-reconnects on any close, which against a billed endpoint means paying twice for the same answer. Use fetch plus a ReadableStream reader instead. EventSource is still fine for GET-based, cookie-authenticated push feeds.
Why does my streamed response arrive all at once?
Almost always proxy buffering, not React. Check nginx proxy_buffering, any CDN or load balancer in front of the origin, and dev-server middleware. Also confirm you read res.body.getReader() rather than await res.text(), which waits for the whole response.
Should I parse markdown on every token?
Parse on every token only if it is cheap. With syntax highlighting or a long document, throttle the render to ~20fps — nobody perceives faster updates. Never mutate the source string to “close” partial syntax; let the parser handle unterminated constructs and re-render when tokens complete them.
Does aborting a stream stop the billing?
Not reliably. Aborting closes the client socket, and upstream generation stops only if your server propagates the disconnect. Tokens already generated are typically billed regardless — one more reason to keep the partial text visible.
What to build first
Ship the hook as written, with the buffer, the streaming decoder, and the single-element update. Add AbortController in the same commit — retrofitting cancellation later means touching every call site. Then get the streaming states and bottom-anchored auto-scroll right; those are what users judge. Markdown rendering comes last, throttled and sanitized.
Do those five things and your UI will feel fast at any model speed: perceived latency is set by the first token, not the last one.


Leave a Reply