Concepts — A Virtualized List Engine
Phase 4 · Spec area §37. Prerequisites: bi-08, bi-09, bi-10.
1. Why this matters
Virtualization is the clearest case in application code where browser internals determine the
architecture. You cannot design it well without knowing what DOM size costs (bi-04), what
layout costs (bi-08), and why scrolling is compositor-driven (bi-10).
It is also where "measure, don't guess" is unavoidable: dynamic row heights require measurement, and measurement forces layout.
2. Build order
Render 100,000 logical rows with a small DOM window.
- Fixed heights: viewport → visible index range → render window + overscan.
- Absolute positioning or transform offset for the window. Choose deliberately and justify it
from
bi-09. - Dynamic heights: measure rendered rows, cache measurements, correct the estimate.
- Scroll anchoring: keep the visually-anchored row stable when an above-viewport measurement changes.
- Fast scroll: what to show when you outrun measurement.
- Accessibility: what a screen reader sees when only 20 of 100,000 rows exist.
3. Failure Lab
- Measurement thrash. Measure every row every frame. Watch forced synchronous layout
(
bi-08). - Anchor drift. Skip scroll anchoring; watch content jump as heights resolve.
- Overscan zero. Blank rows on fast scroll — the app-level analogue of checkerboarding
(
bi-10). - DOM growth leak. Recycle rows incorrectly so the DOM grows; find it in a heap snapshot.
- Scroll handler on the main thread. Update the window in a
scrollhandler; measure the one-frame lag. Compare anIntersectionObserver/sentinel approach.
3.5 Deep dive: the measurement problem
Fixed heights make position arithmetic O(1): offset = index * rowHeight. Dynamic heights destroy
that, and the resulting problems are all one problem — you cannot know the total height without
measuring every row, and you cannot measure a row without rendering it.
The standard resolution is estimate-then-correct:
- Assume an estimated height for unmeasured rows.
- Measure rows as they render; cache by item id (not by index — indices shift).
- Maintain a prefix-sum structure so "what is the offset of row N" stays fast.
- When a measurement differs from the estimate, correct the scroll offset if the changed row is above the viewport, or the content under the user's finger jumps.
Step 4 is the one that separates a working virtualizer from a demo. It is scroll anchoring
(bi-08), implemented by hand, and it is why the browser's built-in version exists.
A prefix-sum / Fenwick tree over row heights gives O(log n) offset lookups and O(log n) updates, versus O(n) for a naive running total recomputed on every measurement. At 100,000 rows that is the difference between smooth and unusable.
3.6 Deep dive: why measurement is expensive, precisely
Measuring means reading geometry, which forces style and layout to be current (bi-08). Doing it
per row, per frame, interleaved with writes, is the forced-synchronous-layout bug at scale.
The mitigations, in order of preference:
ResizeObserver— delivered inside the rendering steps, after layout, so it does not force a synchronous flush (bi-11).- Batch reads, then writes — measure everything, then apply all offsets.
IntersectionObserverfor visibility rather than repeatedgetBoundingClientRect.content-visibility: autowithcontain-intrinsic-size— let the engine skip layout for offscreen content and supply an estimate, which is the platform doing your job.
Option 4 is worth serious consideration before writing a virtualizer at all: it keeps every row in the DOM (so find-in-page, accessibility, and anchor links work) while skipping their layout and paint. It does not reduce DOM size, so it is not a substitute at 100,000 rows — but at 2,000 it frequently is.
3.7 Deep dive: what virtualization breaks
This is the section most tutorials omit, and it is where the real engineering judgement is.
| Breaks | Why | Mitigation |
|---|---|---|
| Find-in-page (Ctrl+F) | offscreen rows are not in the DOM | none reliable — a genuine loss |
| Screen reader navigation | AT sees 20 of 100,000 rows | aria-setsize / aria-posinset, and a real grid role |
Anchor links / scrollIntoView | target may not exist | route through your own index → scroll |
| Tab order | focusable elements appear and disappear | manage focus on recycle; never leave focus on a removed node |
| Text selection across rows | selection breaks at the window edge | rarely fixable |
| only the window prints | render an unvirtualized print view | |
| Browser scroll restoration | height changes as rows measure | manual restoration (fw-08) |
The accessibility row deserves emphasis. aria-setsize and aria-posinset let you tell assistive
technology "this is item 4,207 of 100,000" even though only 20 exist — an author-supplied
promise substituting for information the runtime cannot observe, which is the fifth instance of
that pattern in this track.
The Principal-level framing: virtualization is not a free optimisation. It trades a set of platform behaviours you got for nothing in exchange for render performance. Whether that trade is right depends on the product — a data grid for analysts, yes; a documentation page, almost certainly not. The engineer who reaches for virtualization by default has not priced the right-hand side.
4. Trade-offs
Fixed vs dynamic heights. Fixed makes position arithmetic O(1) and is often a lie about the data.
Bigger overscan. Fewer blanks, more DOM, more layout.
transform vs top. bi-09's property-tree argument, applied.
Virtualization vs content-visibility. The platform now offers a partial answer. When does
the built-in beat the library?
5. Principal Engineer Review
- Explain why virtualization helps, in terms of which pipeline stages it removes work from.
- Dynamic measurement forces layout. Design the scheme that minimises it; state what you give up.
- Argue that
content-visibility: automakes list virtualization obsolete. Then defeat it. - What does virtualization do to accessibility and find-in-page, and what is your mitigation?
- A 100k-row grid must support sort, filter, and inline edit. Where does virtualization stop being the hard part?