The Ticket Said Virtualize

September 12, 2026 (1w ago)

This work spans August–September 2026. Numbers are from a production build measured against a synthetic 1,000-line project; your bottlenecks will differ — that's rather the point of the post.

The same browser-based AI video editor as my previous posts. The editor page is four surfaces on one screen: a script editor (rich text, one line per narrated dialogue), a chapter slide rail, a canvas, and a timeline. On big projects — around 1,000 script lines — typing felt laggy.

The ticket came with the solution already attached: "virtualize the script editor." Render only the visible lines, like every list-performance article says. Reasonable. Obvious, even.

It was wrong, and I'm glad I checked before building it.

Read the code before accepting the premise

Before writing any virtualization code, I read what a script line actually costs. Each line was ~3 DOM nodes with no per-line event listeners. Virtualizing the text editor would remove cheap nodes while breaking the hardest things about a rich-text editor — selection, IME composition, scroll anchoring — for little gain.

The expensive things were elsewhere, and none of them were "too many lines rendered":

So I proposed a different plan to the team: baseline first, fix what the profiler indicts, re-measure against the acceptance criteria, and only then virtualize if we're still short. We never got to the virtualization step.

Build the ruler before the furniture

Nothing above counts as knowledge until it's a number. The first real deliverable was a measurement harness: Playwright driving a production build (dev-mode numbers are fiction) against a synthetic 1,000-line fixture, capturing input-to-paint latency percentiles, long tasks, scroll frame times, and DOM counts — results committed as JSON next to the code.

Baseline: typing input-to-paint p95 248ms, DOM 31,536 elements, hover p95 58ms, ~18s to an editable page.

The harness turned out to be the most valuable artifact of the whole project. Every later claim in this post is a diff between two of its JSON files, and it now runs as a budget gate that fails on regression. In two months it proved every unmeasured intuition wrong at least once — including two of mine, which we'll get to.

Round one: hot paths, not architecture

Fixing the indicted list — windowing the overlay to match visible lines, scoping the highlight sweeps, coalescing the mouse listeners, deleting the dead serialization — plus CSS content-visibility on lines:

Real progress — and still failing the <100ms target. The DOM was half the size, the overlay was windowed, and typing barely moved. Whatever was left wasn't about rendering lines. This is where virtualization was formally dropped: it attacks a cost we had already halved with cheaper tools, and the residual lived somewhere else entirely.

The actual problem: one array, four surfaces

Here's the architectural fact the profiler kept pointing at: all four surfaces of the editor page are projections of one array — the project's chapter/dialogue data. Around 72 components subscribed to that whole array. Any keystroke eventually produced a new array identity, and every subscriber re-rendered: the slide rail, the timeline, the canvas — surfaces that had nothing to do with the character you just typed.

"Typing is slow" didn't mean the text editor was slow: one details array feeds the script editor, slide rail, canvas, and timeline, with ~72 whole-array subscribers re-rendering on every edit.

"The editor is slow" was never really about the editor. It was the rest of the page reacting to the editor.

Two changes followed from that diagnosis:

Structural sharing. When an edit touches chapter 3, chapters 1, 2, 4, 5… keep their exact object identity, so any component keyed to an unchanged chapter can bail out of re-rendering with a pointer comparison.

An incremental sequence calculator. The heaviest per-keystroke computation rebuilt the full playback sequence — every chapter, every dialogue, deep-cloning as it went (~40ms per run, multiple runs per keystroke). The replacement recomputes only the edited chapter and reuses every other chapter's result by identity, returning read-only projections instead of clones:

The incremental calculator recomputes only the edited chapter and reuses the rest via identity checks — 0.04ms versus recomputing and cloning everything.

We also considered the fashionable fix — move the computation to a Web Worker. The harness said no: structuredClone-ing the inputs across the thread boundary cost 0.90ms, the full recompute itself only 0.29ms, and the incremental version 0.04ms. Shipping the data to the worker cost three times more than just doing the work. Another obvious solution, measured and declined.

One contract from this work I've reused since: functions that "update" state must return the original object when nothing changed. Identity is information — downstream memoization is only as good as your upstream discipline about not manufacturing new-but-equal objects.

Know how your metric lies

The subtlest finding wasn't in the app — it was in the measurement. Browser event-timing only records interactions above ~16ms, so the latency distribution is censored: as you get faster, cheap events vanish from the denominator and p95 gets dominated by whatever rare expensive event remains. Concretely: if even one state flush lands mid-typing-burst, it is the p95.

That reshaped the debounce design. The intuitive setup — short delay plus a max-wait ceiling — guarantees mid-burst flushes by design. Worse, a short delay re-arms right after each app stall, which we traced producing a self-sustaining ~1.3-second flush cycle while typing continued. The shipped version is a longer trailing-only debounce that reads state at fire time, paired with a flush-on-leave registry so nothing is lost on tab close or navigation (the flush queue drains LIFO — the editor's flush must enter before the guard drains).

It also means honest reporting: on identical code, p95 re-measures anywhere in a 24–136ms band (p50 is a rock-steady 16ms), because the censored denominator shifts run to run. We commit the band, not a cherry-picked point — and the budget gate asserts thresholds chosen with that variance in mind.

The scoreboard

Metric (1,000-line project, production build) Baseline Final
Typing input→paint p95 248ms 80ms (runs vary 24–136ms)
Typing input→paint p50 ~16ms 16ms
Long-task time per typing burst ~3,100ms ~350ms
Hover p95 58ms 10ms
Frames >32ms while scrolling present 0
DOM elements 31,536 14,041
Time to editable 18.4s ~11.6s

Epilogue: auditing the rest of the page

My fixes had focused on the script editor, canvas, and timeline — so the natural question was what the rest of the editor page was hiding. I closed the arc with a whole-page investigation: instrument first, rank later. It produced my favorite scorecard of the project:

What we guessed versus what the profiler said: virtualization was the wrong lever, the top-ranked subscriber theory measured 8.4ms and was closed, thumbnail recapture measured zero — and the real bottleneck was 46 seconds of audio decoding nobody had ranked at all.

What I took away

  1. Treat the ticket's solution as a hypothesis, not a spec. "Virtualize the editor" named a technique, not a cause. Reading the code for ten minutes falsified the premise before it cost weeks.
  2. The harness outlives every fix. Percentiles from a production build, committed as JSON, wired into a budget gate — it's the only reason we could close theories instead of arguing about them, and it's the project's permanent regression net.
  3. Identity is an API. In a state-driven UI, not allocating a new object is a performance feature with a contract attached. Structural sharing upstream is what makes every memo downstream real.
  4. Understand your metric's failure modes. A censored latency distribution changed our debounce design and our reporting. If you don't know how your number lies, it will lie to you at the worst moment.
  5. The scoreboard must include the work you didn't do. Virtualization: declined. Web Worker: declined. My top-ranked follow-up: closed at 8.4ms. The 46-second bottleneck none of us predicted: found. That asymmetry — plausible ideas killed, unglamorous truth surfaced — is what measuring first actually buys.