#AI#React#TypeScript

AI Streaming UI Commit-Frequency Budget: Frame Coalescing, INP, and Long Animation Frames

Calling setState on every token off the network multiplies React commits. This article constrains only the frequency and freshness from event to commit, using per-frame coalescing, Event Timing, and LoAF to build release gates.

Key Points

  • Topic Focus: AI、React、TypeScript
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

In AI chat, network throughput and commit frequency are not the same contract. The server may push 40 fragments per second, but the browser has no obligation to perform 40 React commits. LLM Streaming Frontend Implementation already covers the basics of token buffering and rAF, and RAG Streaming UI covers throttling. This article only turns "the count and freshness from event to commit" into a reproducible contract; the parsing work within each commit is the responsibility of AI Streaming Markdown Single-Parse Budget.

Define the Commit-Frequency Budget Only After Fixing the Workload

The performance report must record React, the full browser build number, OS, CPU, screen refresh rate, production build digest, and test data digest. The baseline fixture is fixed at 90 seconds, 6000 Chinese characters, 30 fragments per second, with fragment sizes replayed from a saved sequence; the page is preloaded with 100 history messages including code, tables, and quotes. The test appends characters to the input box every 500 ms, clicks a tool button every 5 seconds, and triggers stop-generation at the end.

On the approved mid-range device and Chromium baseline, you can start with the following release lines and then calibrate with real-user percentiles: P75 of duration after aggregation per interaction no higher than 200 ms; P95 of Profiler subtree actualDuration no higher than 12 ms, and max no higher than 32 ms; per foreground active minute, the count of LoAFs, the duration distribution, and the per-entry sum of entry.blockingDuration no higher than the approved baseline; P95 delay from fragment received to committed no higher than 100 ms. The final text summary must exactly match the input fixture; you cannot trade dropped fragments for low latency. Core Web Vitals explains the metric meanings; here we additionally keep stream_queue_to_commit_ms, because INP does not tell you how long a given token waited.

Event Timing only counts qualifying user interactions; a short test cannot treat an unfinished page-level INP as a real-time gauge. The Observer uses { type: "event", buffered: true, durationThreshold: 16 }, discards non-interaction entries with interactionId === 0, and groups by interactionId taking the max duration of that interaction before computing percentiles; otherwise the default 104 ms threshold will miss fast interactions, and a single pointer or keyboard action will be counted multiple times. Automation collects this aggregated value, Long Animation Frames entries, Profiler subtree render duration, and custom fragment timestamps in parallel; page-level INP uses the web-vitals lifecycle semantics and is reported via Web Performance RUM. These four data types cannot substitute for one another.

The Inbound Buffer Commits Only at Paint Boundaries

The network reader is only responsible for appending validated fragments to a mutable buffer and saving receivedAt for the earliest undisplayed fragment. If the foreground page has no pending commit callback, register a requestAnimationFrame; the callback swaps the buffer snapshot and updates the current answer in one shot. New fragments arriving during commit enter the next frame; they cannot mutate the snapshot currently being rendered.

tsx
function enqueueText(part: string, receivedAt: number) {
  pending.text += part;
  pending.firstAt ??= receivedAt;
  if (frameId === null) frameId = requestAnimationFrame(flushFrame);
}

function flushFrame(frameStartedAt: number) {
  frameId = null;
  const batch = takePending();
  if (!batch.text) return;
  setVisibleText((text) => text + batch.text);
  markRenderQueued(batch.firstAt, frameStartedAt);
  if (pending.text) frameId = requestAnimationFrame(flushFrame);
}

requestAnimationFrame aligns with paint opportunities but does not guarantee React has finished committing or pixels have shown; therefore this only registers queue-to-rAF. The component registers commit in a layout effect; double rAF or a requestPostAnimationFrame polyfill can only approximate post-paint; strict pixel visibility must be verified with browser traces or visual fixtures, with the error recorded. The buffer must also have a foreground cap of 8 KiB or 50 ms; exceeding it means the main thread cannot drain, and you should degrade rich text rather than back up indefinitely. When the page is hidden rAF is throttled; the logic switches to a low-frequency bounded refresh and is excluded from the foreground denominator; on done, error, or cancel, the current snapshot must be flushed. Connection cancellation and upstream cleanup still follow Next.js AI Streaming Response Governance; render coalescing cannot masquerade as network backpressure.

startTransition can mark non-urgent answer updates as interruptible work, but it does not shorten expensive Markdown parsing, nor does it automatically coalesce every inbound event. The input box's controlled state stays an urgent update; the answer area can transition at batch boundaries; if transition keeps restarting and pushes visible latency over the line, switch back to a smaller synchronous commit instead of guessing by raising the priority further.

Manufacture Worst-Case Frames with Deterministic Replay

Tests run at both 60 Hz and 120 Hz, with 4× CPU slowdown, continuous text selection, scrolling, quote expansion, and stop-generation. Deliberately inject 200 small fragments within a single frame, and a 64 KiB fragment in the next frame, checking the memory cap, ordering, Unicode integrity, and final summary. Strict Mode and source maps in dev change the commit count; the gate must run on the production build; debug profiling builds are used only for attribution.

During rollout, compare by app version and device tier: INP, LoAF count and blocking duration per foreground minute, queue-to-commit, visual trace, and final summary errors. Browsers that don't support LoAF report unavailable; they cannot be recorded as zero. Start with 1%; only ramp up after a full peak window passes with no threshold breach. The rollback switch restores the previous fixed 50-ms batcher while keeping both old and new metric fields; if the new implementation drops characters, keeps updating after stop, raises P75 INP by 20%, or pushes queue-to-commit past 150 ms, roll back immediately. Reliable smoothness is not "characters appearing more choppily"; it is complete, timely, and non-blocking of user actions within a finite frame budget.

Further Reading

Related

Related Content