← All posts
Engineering

The model is streaming. Why is the text still jumping?

Evanchen By Evanchen
A blue staircase shows model output arriving in bursts, while a continuous green curve shows the browser catching up frame by frame according to its pending render backlog. The two meet immediately at the terminal event.

The model may be streaming, but the text on screen does not necessarily look like it is.

A model can keep returning native deltas, yet those deltas pass through a provider, an Agent Runtime, event buffering, and WebSocket delivery before they reach the browser. By then, the browser often receives them in batches. If the Web UI inserts each entire batch into React as soon as it arrives, users see a repeating pattern: nothing, then a jump of text, then nothing again.

That does not mean the model stopped generating. It does not necessarily mean the network slowed down either. The problem is that we treated two different clocks as one.

Streaming output has two clocks: when events arrive, and when text enters the next screen paint.

requestAnimationFrame is not the same as smoothing

mosoo’s server does not send a separate WebSocket message for every tiny delta. Viewer events are compressed and merged for up to 150ms. The buffer flushes early when it reaches 4KB or 64 events, and also for the first delta or a terminal Run event. Those limits live directly in the delivery buffer, while the fast paths for the first delta and terminal events have their own explicit conditions.

This is a necessary systems tradeoff. Batching reduces message count, serialization work, and downstream state updates. The cost is that the browser’s input rhythm no longer matches the model’s generation rhythm.

The old implementation already used requestAnimationFrame to combine rendering work, but it delivered the entire queued batch on the next frame. rAF answers when to commit. It does not answer how much to commit in that frame. If one WebSocket message already contains roughly 150ms of output, delivering all of it on the next frame still produces a jump.

The control variable is pending backlog, not a fixed typing speed

The obvious solution is a typewriter: reveal one character every few milliseconds. A fixed speed, however, cannot adapt to slow models, fast models, short tails, and sudden large batches at the same time.

mosoo uses a different control variable: the number of graphemes already received but not yet rendered.

pending = received - rendered
rate = clamp(pending / 0.25s, 20, 800)
budget += rate * elapsedSeconds
emit = floor(budget)

The larger the backlog, the faster the next frame catches up. The smaller the backlog, the gentler the pace. A time constant of τ = 250ms keeps a transport batch of roughly 150ms in flight until the next batch is likely to arrive, turning adjacent steps into a continuous stream. The 20–800 graphemes-per-second bounds prevent a short tail from dragging on forever and prevent the animation from becoming too fast to read.

A blue staircase shows the cumulative text received by the browser, while a continuous green
curve shows cumulative rendered text. The larger the gap, the faster the green curve catches up;
the terminal event closes the gap
immediately.

The browser does not replay every model token. It uses a second clock to follow the first. Each frame’s budget is driven by backlog, not by a fixed typing speed.

The budget uses the actual elapsed time Δt instead of assuming that every frame lasts exactly 16ms. A 60Hz display, a 120Hz display, and a throttled page therefore produce similar visible progress over the same wall-clock interval. A budget smaller than one grapheme is not discarded; its fractional remainder carries into the next frame. The complete budget calculation and queue slicing live in one Scheduler.

The unit of slicing is a grapheme, not a UTF-16 index

Slicing by JavaScript string index can split a surrogate pair, combining character, or ZWJ emoji. For a moment, the UI may render or half an emoji.

When an event enters the queue, mosoo uses Intl.Segmenter to split it into grapheme clusters in advance, with code-point iteration as the fallback. That work happens once, and the per-frame budget reuses the result. This preserves text correctness without rescanning the same string on every frame.

Smoothing must know when to stop

A Scheduler that only knows how to “show text slowly” is incomplete. Animation must yield to event semantics.

When the queue contains Message End, Reasoning End, a terminal Run event, or a State / Messages Snapshot after reconnection, mosoo immediately delivers the preceding backlog. The server has already established that the segment is complete, so there is no reason to keep playing stale animation. These events are explicit pacing barriers in the code.

Likewise, when the backlog exceeds 4,096 graphemes, the Scheduler delivers the whole batch immediately. Continuing at the maximum animation rate could still leave the screen seconds behind the real state. Stopping the polish under overload is part of the performance strategy.

Pending text enters a per-frame budgeter. An active stream stays smooth, an END event flushes
immediately, backlog over 4,096 is delivered as a batch, and a 50ms timer takes over when
requestAnimationFrame is paused.

Smoothing exists only while generation is active and backlog is under control. Correctness, terminal state, and freshness take priority.

Several other boundaries matter:

After a WebSocket event arrives, the Scheduler submits at most one batch to Live State per frame. Closing the connection triggers an explicit flush. That integration boundary stays inside the Session Stream Socket.

Low overhead is not a magic number

This change adds no animation library and creates no timer per character. It reuses the browser’s frame clock and handles budgeting, slicing, and batched state updates in one queue:

  1. Graphemes are segmented once, when they enter the queue.
  2. Each frame computes an integer budget from actual elapsed time.
  3. A frame triggers at most one batched state update.
  4. Only user-visible text deltas are paced; structural events do not receive a character-by-character animation.
  5. Terminal state and excessive backlog both have direct exit paths.

These are structural constraints for keeping overhead low, not an unmeasured performance promise. PR #450 focuses its verification on timing semantics: controlled clocks cover 60Hz / 120Hz consistency, emoji boundaries, terminal flushing, reconnection snapshots, strict ordering, hidden-page fallback, and excessive backlog. Each case is visible in the test file. We did not run production CPU or long-task benchmarks for this change, so we do not turn “smoother” into an unsupported claim that it is “measurably faster.”

A reusable principle

If you are building a Web UI on top of native model output, start with four rules:

  1. Let the first delta bypass transport buffering to protect time to first text.
  2. During generation, adjust the per-frame rate from backlog instead of using a fixed typing speed.
  3. Flush terminal events and snapshots immediately; explicitly flush or clear the queue when the connection closes or the active Session changes.
  4. Stop animating when backlog escapes control. Freshness matters more than smoothness.

The transport layer reduces system overhead. The rendering layer maintains perceptual continuity. Event semantics decide when both must yield.

That is the core of mosoo PR #450: we did not change how the model produces output or fabricate more tokens. We gave the browser a bounded display clock, with explicit exit paths, that faithfully catches up with the model’s native stream.


← More posts Learn More →