javal.ge
javascript

Fix LLM Streaming in React: Prevent Re-renders, Jank, and Token Flooding

import { useEffect, useMemo, useRef, useState, startTransition } from "react";

export function useLLMStream() {
  const [text, setText] = useState("");
  const [isStreaming, setIsStreaming] = useState(false);

  const abortRef = useRef(null);
  const bufferRef = useRef("");
  const flushTimerRef = useRef(null);

  const flush = useMemo(() => {
    let last = 0;
    return (force = false) => {
      const now = performance.now();
      if (!force && now - last < 50) return; // cadence: 50ms
      last = now;

      const chunk = bufferRef.current;
      if (!chunk) return;
      bufferRef.current = "";

      startTransition(() => {
        setText((t) => t + chunk);
      });
    };
  }, []);

  useEffect(() => {
    return () => {
      if (abortRef.current) abortRef.current.abort();
      if (flushTimerRef.current) clearInterval(flushTimerRef.current);
    };
  }, []);

  async function start(prompt) {
    // cancel any in-flight stream
    if (abortRef.current) abortRef.current.abort();

    const ac = new AbortController();
    abortRef.current = ac;

    setText("");
    bufferRef.current = "";
    setIsStreaming(true);

    // flush buffer on a steady cadence
    if (flushTimerRef.current) clearInterval(flushTimerRef.current);
    flushTimerRef.current = setInterval(() => flush(false), 50);

    try {
      const res = await fetch("/api/llm/stream", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ prompt }),
        signal: ac.signal
      });

      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      if (!res.body) throw new Error("Missing response body stream");

      const reader = res.body.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { value, done } = await reader.read();
        if (done) break;

        // Decode bytes to string (stream-safe)
        const str = decoder.decode(value, { stream: true });
        bufferRef.current += str;

        // Safety: if buffer is huge, flush immediately
        if (bufferRef.current.length > 32_000) flush(true);
      }

      // final flush
      flush(true);
    } finally {
      setIsStreaming(false);
      if (flushTimerRef.current) {
        clearInterval(flushTimerRef.current);
        flushTimerRef.current = null;
      }
    }
  }

  function stop() {
    abortRef.current?.abort();
  }

  return { text, isStreaming, start, stop };
}

Problem: token streaming makes your UI laggy

  • Streaming one token at a time into React state triggers excessive renders.
  • Markdown rendering on every chunk is expensive and causes input/scroll jank.
  • Fast streams can overwhelm the main thread and make the page feel frozen.

Solution: buffer chunks, flush on a schedule, and keep the DOM stable

  • Accumulate streamed text in a ref (no render).
  • Flush to state at a fixed cadence (e.g., every 50ms) or on animation frames.
  • Use startTransition so updates don’t block user input.
  • Abort previous streams to avoid interleaving responses.

Implementation (fetch + ReadableStream)

  • Use an AbortController per request.
  • Decode bytes with TextDecoder and append to a buffer ref.
  • Flush buffered text on an interval; keep the interval short but not per-token.

Extra hardening

  • Backpressure: if the buffer grows too large, flush immediately (or reduce cadence) to avoid memory spikes.
  • Scrolling: avoid calling scrollIntoView per token—do it per flush, or only when user is at bottom.
  • Markdown: defer parsing; render plain text while streaming and convert to markdown once completed (or parse per flush).
Back to Tips

🍪 Cookie Consent

We use cookies to enhance your browsing experience, serve personalized content, and analyze our traffic. By clicking "Accept", you consent to our use of cookies.