{"id":126,"date":"2026-09-17T01:55:27","date_gmt":"2026-09-16T17:55:27","guid":{"rendered":"https:\/\/wp.qoraapi.com\/streaming-chat-ui-react\/"},"modified":"2026-09-20T02:50:44","modified_gmt":"2026-09-19T18:50:44","slug":"streaming-chat-ui-react","status":"publish","type":"post","link":"https:\/\/qoraapi.com\/blog\/streaming-chat-ui-react\/","title":{"rendered":"Building a Streaming Chat UI in React: Patterns for SSE Responses"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A streaming chat UI in React reads a Server-Sent Events response with <code>fetch<\/code> plus a <code>ReadableStream<\/code> reader, decodes each chunk with <code>TextDecoder<\/code>, splits complete <code>data:<\/code> frames, and appends token deltas to the last assistant message in state. Use <code>fetch<\/code>, not <code>EventSource<\/code>: chat completions need POST, a JSON body, and an <code>Authorization<\/code> header.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That is the whole mechanism. The rest of this guide covers what breaks in production \u2014 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 <a href=\"https:\/\/qoraapi.com\/blog\/ai-api-streaming-sse\/\">AI API streaming<\/a> guide.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why stream in the UI at all<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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 \u2014 from roughly four seconds to roughly three hundred milliseconds.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 \u2014 and for long answers it genuinely <em>is<\/em> faster, because the user reads while the model writes. Streaming converts dead waiting time into reading time.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>The first token must paint in the frame it arrives.<\/strong> Any queue, debounce, animation, or &#8220;wait for the full response then reveal&#8221; step cancels the benefit. Debouncing is right for markdown parsing (below) and wrong for appending text.<\/li>\n<li><strong>You are rendering text you cannot take back.<\/strong> 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 \u2014 which is where <a href=\"https:\/\/qoraapi.com\/blog\/ai-structured-outputs-json-mode\/\">structured outputs<\/a> earn their place for anything machine-read.<\/li>\n<li><strong>The naive implementation is O(n&sup2;).<\/strong> 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.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Decision criterion:<\/strong> stream every response a human is waiting on and will read. Do not stream classification, extraction, or embeddings \u2014 buffering is less code, and no one is watching.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Consuming SSE in the browser: EventSource vs fetch + ReadableStream<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Both APIs can consume <code>text\/event-stream<\/code>. The choice is decided by three constraints, not by preference: HTTP method, request headers, and reconnect behavior.<\/p>\n\n\n\n<figure class=\"wp-block-table is-style-stripes\"><table class=\"has-fixed-layout\"><thead><tr><th>Constraint<\/th><th><code>EventSource<\/code><\/th><th><code>fetch<\/code> + <code>ReadableStream<\/code><\/th><\/tr><\/thead><tbody><tr><td>HTTP method<\/td><td>GET only<\/td><td>Any \u2014 POST for chat completions<\/td><\/tr><tr><td>Request body<\/td><td>Not supported<\/td><td>Full JSON body, including the message array<\/td><\/tr><tr><td>Custom headers<\/td><td>Not supported (no <code>Authorization<\/code>)<\/td><td>Full control<\/td><\/tr><tr><td>Auth options<\/td><td>Cookies, or a key in the query string<\/td><td><code>Authorization: Bearer \u2026<\/code><\/td><\/tr><tr><td>Auto-reconnect<\/td><td>Yes, built in, with <code>Last-Event-ID<\/code><\/td><td>No \u2014 you implement it<\/td><\/tr><tr><td>Frame parsing<\/td><td>Done for you via <code>onmessage<\/code><\/td><td>You split <code>data:<\/code> frames<\/td><\/tr><tr><td>Cancellation<\/td><td><code>.close()<\/code><\/td><td><code>AbortController<\/code><\/td><\/tr><tr><td>Best fit<\/td><td>GET-based, cookie-auth push feeds<\/td><td>OpenAI-compatible chat completions<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The rule: if the request is a GET with no body and cookie auth, <code>EventSource<\/code> is less code and reconnection is free. The moment you need POST, a body, or a bearer token \u2014 every chat completion \u2014 use <code>fetch<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The CORS and auth caveat that rules out EventSource for chat<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>EventSource<\/code> cannot set request headers. That leaves two ways to authenticate it, both unacceptable for a provider key:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Key in the query string.<\/strong> It lands in server logs, browser history, and any <code>Referer<\/code> header. A credential in a URL is a credential you have to rotate.<\/li>\n<li><strong>Cookie auth.<\/strong> Requires <code>credentials: 'include'<\/code> and a server echoing an exact <code>Access-Control-Allow-Origin<\/code> \u2014 wildcards are forbidden once credentials are involved.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">There is a quieter trap too. <code>EventSource<\/code> auto-reconnects on <em>any<\/em> 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 <code>fetch<\/code>, a closed stream is just a closed stream.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Before you debug React: <code>fetch<\/code> gives <code>text\/event-stream<\/code> no special behavior \u2014 you receive an opaque byte stream and own the framing.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">A minimal React hook for streaming<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This hook runs as written. It sends the conversation, streams the reply, appends deltas to the last assistant message, and exposes a <code>stop()<\/code> that aborts cleanly.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import { useCallback, useRef, useState } from \"react\";\n\ntype Msg = { role: \"user\" | \"assistant\"; content: string };\n\nexport function useChatStream(\n  endpoint: string, \/\/ your own \/api\/chat route \u2014 never a provider key in the browser\n  token: string, \/\/ short-lived session token for your route\n  model = \"gpt-4o-mini\"\n) {\n  const [messages, setMessages] = useState&lt;Msg[]&gt;([]);\n  const [isStreaming, setIsStreaming] = useState(false);\n  const [error, setError] = useState&lt;string | null&gt;(null);\n  const abortRef = useRef&lt;AbortController | null&gt;(null);\n  const bufRef = useRef(\"\"); \/\/ carries a partial SSE frame across reads\n\n  const stop = useCallback(() =&gt; {\n    abortRef.current?.abort();\n    abortRef.current = null;\n    setIsStreaming(false);\n  }, []);\n\n  const send = useCallback(\n    async (text: string) =&gt; {\n      const history: Msg[] = [...messages, { role: \"user\", content: text }];\n      setMessages([...history, { role: \"assistant\", content: \"\" }]);\n      setError(null);\n      setIsStreaming(true);\n      bufRef.current = \"\";\n\n      const ac = new AbortController();\n      abortRef.current = ac;\n\n      \/\/ Touch only the last message: O(1) per token, not O(n).\n      const appendDelta = (delta: string) =&gt;\n        setMessages((prev) =&gt; {\n          const next = prev.slice();\n          const last = next[next.length - 1];\n          next[next.length - 1] = { ...last, content: last.content + delta };\n          return next;\n        });\n\n      try {\n        const res = await fetch(endpoint, {\n          method: \"POST\",\n          signal: ac.signal,\n          headers: {\n            \"Content-Type\": \"application\/json\",\n            Authorization: `Bearer ${token}`,\n          },\n          body: JSON.stringify({ model, messages: history, stream: true }),\n        });\n\n        if (!res.ok || !res.body) {\n          throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);\n        }\n\n        const reader = res.body.getReader();\n        const decoder = new TextDecoder();\n\n        while (true) {\n          const { value, done } = await reader.read();\n          if (done) break;\n\n          \/\/ stream: true keeps a split multibyte character in the decoder.\n          bufRef.current += decoder.decode(value, { stream: true });\n\n          const frames = bufRef.current.split(\"\\n\\n\");\n          bufRef.current = frames.pop() ?? \"\"; \/\/ keep the incomplete tail\n\n          for (const frame of frames) {\n            for (const line of frame.split(\"\\n\")) {\n              if (!line.startsWith(\"data:\")) continue;\n              const payload = line.slice(5).trim();\n              if (payload === \"[DONE]\") return;\n              try {\n                const json = JSON.parse(payload);\n                const delta = json.choices?.[0]?.delta?.content;\n                if (delta) appendDelta(delta);\n              } catch {\n                \/\/ Half a JSON object: wait for the next read.\n              }\n            }\n          }\n        }\n      } catch (e) {\n        \/\/ An aborted fetch rejects \u2014 that is not an error to show anyone.\n        if ((e as Error).name !== \"AbortError\") setError((e as Error).message);\n      } finally {\n        setIsStreaming(false);\n        abortRef.current = null;\n      }\n    },\n    [endpoint, token, model, messages]\n  );\n\n  return { messages, send, stop, isStreaming, error };\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Four details carry correctness:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong><code>bufRef<\/code> exists because read boundaries are arbitrary.<\/strong> One <code>read()<\/code> can return half a frame, three frames, or one frame split mid-JSON-string. Splitting on <code>\\n\\n<\/code> and popping the last element back into the buffer keeps the final chunk of every response from being dropped.<\/li>\n<li><strong><code>decoder.decode(value, { stream: true })<\/code> is mandatory.<\/strong> Without the streaming flag, a multibyte character split across two reads \u2014 any emoji, any CJK text \u2014 decodes into replacement characters. Invisible in English-only testing; it appears the week you add a non-English user.<\/li>\n<li><strong>Replace one array element, not the whole history.<\/strong> Swapping the last message is constant work per token; rebuilding history with <code>map()<\/code> makes a long conversation quadratic.<\/li>\n<li><strong>Swallow <code>AbortError<\/code>.<\/strong> Aborting a fetch rejects the promise by design. Surfacing that turns a deliberate cancel into a red error banner.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Two caveats. <code>send<\/code> closes over <code>messages<\/code>, so its identity changes every token \u2014 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Rendering markdown as it arrives<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The hard part is not markdown \u2014 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 <code>[docs](https:\/\/exa<\/code>. A table has one column and no separator row.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Never repair the source string.<\/strong> Do not append synthetic closing fences or <code>**<\/code> markers to make the text parse \u2014 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.<\/li>\n<li><strong>Sanitize the output, and prefer never producing HTML.<\/strong> <code>react-markdown<\/code> builds React elements and escapes embedded HTML unless you opt in with <code>rehype-raw<\/code> \u2014 and the moment you add <code>rehype-raw<\/code>, <code>rehype-sanitize<\/code> stops being optional, because it strips <code>&lt;script&gt;<\/code>, <code>onerror=<\/code>, and <code>javascript:<\/code> URLs. If you inject an HTML string instead, sanitize after markdown conversion, with an allowlist.<\/li>\n<li><strong>Do not highlight code per token.<\/strong> 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.<\/li>\n<li><strong>Suppress interactive elements while incomplete.<\/strong> A half-typed URL should not be a live anchor \u2014 a user can and will click it mid-stream. Render links as plain text until the message is done.<\/li>\n<li><strong>Throttle the parse, not the append.<\/strong> 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.<\/li>\n<\/ul>\n\n\n\n<pre class=\"wp-block-code\"><code>import { memo, useEffect, useState } from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport rehypeSanitize from \"rehype-sanitize\";\n\n\/** Coalesce rapid updates. On completion, render the final text immediately. *\/\nfunction useThrottled(value: string, ms: number) {\n  const [v, setV] = useState(value);\n  useEffect(() =&gt; {\n    if (ms === 0) return setV(value);\n    const id = setTimeout(() =&gt; setV(value), ms);\n    return () =&gt; clearTimeout(id);\n  }, [value, ms]);\n  return v;\n}\n\nexport const StreamingMarkdown = memo(function StreamingMarkdown({\n  text,\n  done,\n}: {\n  text: string;\n  done: boolean;\n}) {\n  \/\/ ~20fps while streaming; unthrottled on the final frame.\n  const shown = useThrottled(text, done ? 0 : 50);\n\n  return (\n    &lt;div className=\"prose\"&gt;\n      &lt;ReactMarkdown\n        remarkPlugins={[remarkGfm]}\n        rehypePlugins={[rehypeSanitize]}\n        components={{\n          \/\/ No live links until the URL has fully arrived.\n          a: ({ href, children }) =&gt;\n            done ? (\n              &lt;a href={href} target=\"_blank\" rel=\"noopener noreferrer\"&gt;\n                {children}\n              &lt;\/a&gt;\n            ) : (\n              &lt;span className=\"link-pending\"&gt;{children}&lt;\/span&gt;\n            ),\n        }}\n      &gt;\n        {shown}\n      &lt;\/ReactMarkdown&gt;\n    &lt;\/div&gt;\n  );\n});<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Abort, cancel, and regenerate<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">One <code>AbortController<\/code> per in-flight request, held in a ref. That one ref gives you cancel, regenerate, unmount cleanup, and timeout \u2014 they are all the same operation.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>One button, two states.<\/strong> While <code>isStreaming<\/code>, the send button becomes Stop \u2014 an obvious cancel affordance that makes a second concurrent completion on the same thread impossible.<\/li>\n<li><strong>Keep the partial text on abort.<\/strong> 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.<\/li>\n<li><strong>Aborting closes the client socket \u2014 upstream is not guaranteed to stop.<\/strong> A relay that ignores the disconnect keeps generating and you keep paying for tokens nobody sees. Treat tokens already emitted as billed.<\/li>\n<li><strong>Regenerate is drop-and-resend.<\/strong> Remove the last assistant message and re-send the same history through the same code path. Keep the previous answer behind a &#8220;show previous&#8221; toggle \u2014 users frequently prefer the first draft.<\/li>\n<li><strong>Abort on unmount.<\/strong> Add the abort call to a <code>useEffect<\/code> cleanup. Without it, a stream keeps writing into a component that no longer exists, and React 18 no longer warns you.<\/li>\n<li><strong>Add a deadline.<\/strong> A stream that never sends its terminator leaves the Stop button spinning forever. Where supported, combine signals with <code>AbortSignal.any([ac.signal, AbortSignal.timeout(60_000)])<\/code>; otherwise a <code>setTimeout<\/code> that calls <code>ac.abort()<\/code> is enough.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">UX details that decide whether it feels good<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Small things, individually invisible, collectively the difference between a demo and a product.<\/p>\n\n\n\n<figure class=\"wp-block-table is-style-stripes\"><table class=\"has-fixed-layout\"><thead><tr><th>State<\/th><th>Trigger<\/th><th>What the user sees<\/th><th>What must not happen<\/th><\/tr><\/thead><tbody><tr><td>Sending<\/td><td><code>send()<\/code> called<\/td><td>User bubble appears instantly<\/td><td>Textarea still holding the text<\/td><\/tr><tr><td>Thinking<\/td><td>Request open, zero tokens<\/td><td>Spinner after a ~400ms delay<\/td><td>Spinner flashing on and off<\/td><\/tr><tr><td>Streaming<\/td><td>First delta appended<\/td><td>Text plus a blinking caret<\/td><td>Scroll hijack while reading<\/td><\/tr><tr><td>Stopped<\/td><td><code>abort()<\/code><\/td><td>Truncated text marked &#8220;Stopped&#8221;<\/td><td>Bubble cleared<\/td><\/tr><tr><td>Error<\/td><td>Non-2xx or network failure<\/td><td>Error card inside the bubble + Retry<\/td><td>The user&#8217;s message lost<\/td><\/tr><tr><td>Done<\/td><td><code>[DONE]<\/code> or reader closed<\/td><td>Caret removed, actions revealed<\/td><td>Links live before completion<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Auto-scroll, correctly.<\/strong> The most common bug in streaming chat UIs is calling <code>scrollIntoView()<\/code> 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 \u2014 measure <code>scrollHeight - scrollTop - clientHeight &lt; 48<\/code> \u2014 and otherwise show a &#8220;Jump to latest&#8221; pill. Someone scrolled up during generation is reading; respect it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Optimistic user bubble.<\/strong> Append the user&#8217;s message and clear the textarea in the same handler, before the <code>await fetch<\/code>. Waiting for a round trip to render the user&#8217;s own text adds perceived latency for zero information.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three visual states, not two.<\/strong> &#8220;Thinking&#8221; and &#8220;streaming&#8221; should look different on screen; collapsing them is why many chat UIs feel broken for the first second after you hit send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Delay the spinner.<\/strong> If TTFT is under ~1.5s, a spinner that appears and vanishes reads as jank. Show it after a 300\u2013500ms 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Keep the composer enabled.<\/strong> 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 <code>min-height<\/code> so the scrollbar does not jump when the first line lands.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do not put <code>aria-live<\/code> on the streaming node.<\/strong> 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 <code>aria-live=\"polite\"<\/code> region when <code>done<\/code> flips true.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The backend contract your UI depends on<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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 <a href=\"https:\/\/qoraapi.com\/blog\/ai-api-streaming-sse\/\">AI API streaming<\/a> guide.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>POST, with <code>\"stream\": true<\/code> in the body.<\/strong> Not a GET with a query parameter.<\/li>\n<li><strong><code>Content-Type: text\/event-stream<\/code>, with buffering off.<\/strong> Behind nginx that means <code>X-Accel-Buffering: no<\/code>; behind Cloudflare, buffering disabled for the route. If tokens all arrive together, this is the cause far more often than your React code.<\/li>\n<li><strong>One JSON object per <code>data:<\/code> line, OpenAI-compatible.<\/strong> Text arrives at <code>choices[0].delta.content<\/code>; the last chunk carries <code>finish_reason<\/code>.<\/li>\n<li><strong>A terminal <code>data: [DONE]<\/code>.<\/strong> Treat a reader that closes without it as a truncation \u2014 otherwise a dropped connection looks like a complete answer.<\/li>\n<li><strong>CORS, if you call it directly from the browser.<\/strong> An exact <code>Access-Control-Allow-Origin<\/code> and <code>Access-Control-Allow-Headers: Authorization, Content-Type<\/code>. Wildcards break the moment credentials are involved.<\/li>\n<li><strong>Never ship a provider key in a browser bundle.<\/strong> Anything in a JS bundle is public. Put a thin route in front: your app streams from your own <code>\/api\/chat<\/code>, which holds the key server-side.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">That server route can point at a single OpenAI-compatible endpoint. <a href=\"https:\/\/qoraapi.com\/\" target=\"_blank\" rel=\"noopener\">qoraapi.com<\/a> is an AI API relay exposing many models \u2014 GPT, Claude, Gemini, open-weight \u2014 behind one base URL and one key, with <code>stream: true<\/code> 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 <a href=\"https:\/\/qoraapi.com\/blog\/ai-structured-outputs-json-mode\/\">structured outputs<\/a> handle; if you are still assembling the request layer, start from our walkthrough on how to <a href=\"https:\/\/qoraapi.com\/blog\/build-ai-chatbot-api\/\">build an AI chatbot<\/a> with the API.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Can I use EventSource for OpenAI-compatible chat streaming?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. Chat completions need a POST with a JSON body and an <code>Authorization<\/code> header; <code>EventSource<\/code> 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 <code>fetch<\/code> plus a <code>ReadableStream<\/code> reader instead. <code>EventSource<\/code> is still fine for GET-based, cookie-authenticated push feeds.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does my streamed response arrive all at once?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Almost always proxy buffering, not React. Check nginx <code>proxy_buffering<\/code>, any CDN or load balancer in front of the origin, and dev-server middleware. Also confirm you read <code>res.body.getReader()<\/code> rather than <code>await res.text()<\/code>, which waits for the whole response.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should I parse markdown on every token?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Parse on every token only if it is cheap. With syntax highlighting or a long document, throttle the render to ~20fps \u2014 nobody perceives faster updates. Never mutate the source string to &#8220;close&#8221; partial syntax; let the parser handle unterminated constructs and re-render when tokens complete them.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Does aborting a stream stop the billing?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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 \u2014 one more reason to keep the partial text visible.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What to build first<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Ship the hook as written, with the buffer, the streaming decoder, and the single-element update. Add <code>AbortController<\/code> in the same commit \u2014 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n\n<h3 class=\"wp-block-heading\">Related reading<\/h3>\n\n\n<ul class=\"wp-block-list\"><li><a href=\"https:\/\/qoraapi.com\/blog\/ai-api-streaming-sse\/\">AI API Streaming Explained: How SSE Works and How to Consume It<\/a><\/li><li><a href=\"https:\/\/qoraapi.com\/blog\/build-ai-chatbot-api\/\">How to Build an AI Chatbot with the API<\/a><\/li><li><a href=\"https:\/\/qoraapi.com\/blog\/ai-structured-outputs-json-mode\/\">AI Structured Outputs Explained: JSON Mode, Schema Enforcement, Reliable Parsing<\/a><\/li><li><a href=\"https:\/\/qoraapi.com\/blog\/ai-mobile-integration\/\">Integrating AI APIs into Mobile Apps<\/a><\/li><\/ul>\n\n","protected":false},"excerpt":{"rendered":"<p>Build a chat UI that streams tokens smoothly: consume SSE with fetch + ReadableStream, render markdown incrementally, and wire abort\/regenerate \u2014 with a working React hook.<\/p>\n","protected":false},"author":1,"featured_media":125,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[5,6,9,7],"class_list":["post-126","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ai-api","tag-ai-api","tag-api-gateway","tag-developer-tools","tag-developers"],"_links":{"self":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts\/126","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/comments?post=126"}],"version-history":[{"count":1,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts\/126\/revisions"}],"predecessor-version":[{"id":183,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts\/126\/revisions\/183"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/media\/125"}],"wp:attachment":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/media?parent=126"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/categories?post=126"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/tags?post=126"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}