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.

  1. Fixed heights: viewport → visible index range → render window + overscan.
  2. Absolute positioning or transform offset for the window. Choose deliberately and justify it from bi-09.
  3. Dynamic heights: measure rendered rows, cache measurements, correct the estimate.
  4. Scroll anchoring: keep the visually-anchored row stable when an above-viewport measurement changes.
  5. Fast scroll: what to show when you outrun measurement.
  6. Accessibility: what a screen reader sees when only 20 of 100,000 rows exist.

3. Failure Lab

  1. Measurement thrash. Measure every row every frame. Watch forced synchronous layout (bi-08).
  2. Anchor drift. Skip scroll anchoring; watch content jump as heights resolve.
  3. Overscan zero. Blank rows on fast scroll — the app-level analogue of checkerboarding (bi-10).
  4. DOM growth leak. Recycle rows incorrectly so the DOM grows; find it in a heap snapshot.
  5. Scroll handler on the main thread. Update the window in a scroll handler; measure the one-frame lag. Compare an IntersectionObserver/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:

  1. Assume an estimated height for unmeasured rows.
  2. Measure rows as they render; cache by item id (not by index — indices shift).
  3. Maintain a prefix-sum structure so "what is the offset of row N" stays fast.
  4. 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.


0. Orientation

The 60-second version

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).

The mechanism is simple: render 20 rows instead of 100,000, positioned so the scrollbar lies convincingly. The engineering is entirely in the consequences, and they resolve to one circular dependency:

You cannot know the total height without measuring every row, and you cannot measure a row without rendering it.

Everything hard follows from that: estimate-then-correct, prefix sums, anchor correction, and the fact that measurement itself forces layout — the fe-01 bug at scale.

The half of this that tutorials omit

Virtualization is not a free optimisation. It trades a set of platform behaviours you got for nothing:

BreaksMitigation
Find-in-page (Ctrl+F)none reliable — a genuine loss
Screen reader navigationaria-setsize / aria-posinset and a real grid role
Anchor links / scrollIntoViewroute through your own index → scroll
Tab ordermanage focus on recycle; never leave focus on a removed node
Text selection across rowsrarely fixable
Printrender an unvirtualized print view
Browser scroll restorationmanual restoration (fw-08)

The engineer who reaches for virtualization by default has not priced the right-hand side. A data grid for analysts, yes. A documentation page, almost certainly not.

Try the platform first

content-visibility: auto with contain-intrinsic-size keeps every row in the DOM — so find-in-page, accessibility and anchor links all work — while skipping their layout and paint. fe-05 measured a 98% cut in initial layout from that one declaration. It does not reduce DOM size, so it is not a substitute at 100,000 rows. At 2,000 it frequently is.

What you should be able to do after this module

CapabilityEvidence you have it
Justify virtualizationName which pipeline stages it removes work from, and what it costs
Design the measurement schemeEstimate, cache by id, prefix-sum, correct the anchor
Avoid measurement thrashReach for ResizeObserver and batched reads before getBoundingClientRect
Decide against itRecognise when content-visibility is the right answer instead

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:

  1. ResizeObserver — delivered inside the rendering steps, after layout, so it does not force a synchronous flush (bi-11).
  2. Batch reads, then writes — measure everything, then apply all offsets.
  3. IntersectionObserver for visibility rather than repeated getBoundingClientRect.
  4. content-visibility: auto with contain-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.

BreaksWhyMitigation
Find-in-page (Ctrl+F)offscreen rows are not in the DOMnone reliable — a genuine loss
Screen reader navigationAT sees 20 of 100,000 rowsaria-setsize / aria-posinset, and a real grid role
Anchor links / scrollIntoViewtarget may not existroute through your own index → scroll
Tab orderfocusable elements appear and disappearmanage focus on recycle; never leave focus on a removed node
Text selection across rowsselection breaks at the window edgerarely fixable
Printonly the window printsrender an unvirtualized print view
Browser scroll restorationheight changes as rows measuremanual 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?

Core terminology

TermDefinition
WindowThe set of rows actually present in the DOM
OverscanExtra rows rendered outside the viewport to absorb fast scrolling
Logical rowAn item in the data; may or may not currently exist in the DOM
Estimated heightThe assumed size of an unmeasured row
Measurement cacheRow heights keyed by item id, not index — indices shift
Prefix sum / Fenwick treeO(log n) offset lookup and update over row heights
Scroll anchoringCorrecting scroll offset when an above-viewport measurement changes
Anchor driftThe jump that happens without it, as estimates resolve into real heights
RecyclingReusing row DOM nodes for different data as the window moves
CheckerboardingThe compositor analogue of blank rows: unrasterised tiles (bi-10)
aria-setsize / aria-posinsetTelling AT "item 4,207 of 100,000" when only 20 exist
content-visibility: autoSkip layout/paint for offscreen content while keeping it in the DOM
contain-intrinsic-sizeThe placeholder size that stops the scrollbar lying
SentinelAn element observed by IntersectionObserver to trigger window updates

Mental models

Fixed heights are O(1) arithmetic and usually a lie about the data. offset = index * rowHeight is wonderful and applies to almost no real content. Dynamic heights destroy the arithmetic and create every hard problem in the module.

Estimate, measure, correct — and correcting is the part that separates a working virtualizer from a demo. When a measurement differs from the estimate and the changed row is above the viewport, you must adjust the scroll offset or the content under the user's finger jumps. That is scroll anchoring (bi-08), implemented by hand, and it is exactly why the browser built its own.

Cache measurements by item id, never by index. Indices shift on insert, delete and sort; ids do not. This is the same identity argument as key in fw-02, and it fails the same way — silently, as content attached to the wrong thing.

Measuring means reading geometry, which forces style and layout to be current. Doing it per row, per frame, interleaved with writes, is fe-01's forced-synchronous-layout bug at scale. Mitigations in order of preference:

  1. ResizeObserver — delivered inside the rendering steps, after layout, so it does not force a synchronous flush.
  2. Batch reads, then writes — measure everything, then apply all offsets.
  3. IntersectionObserver for visibility rather than repeated getBoundingClientRect.
  4. content-visibility: auto with contain-intrinsic-size — the platform doing your job.

aria-setsize is an author-supplied promise substituting for information the runtime cannot observe. Fifth instance of the pattern in this track, after key, {passive:true}, contain, and sideEffects. Collect them.

Virtualization is a trade, and the right-hand side has to be priced. Not "does this make the list fast" but "which platform behaviours am I giving up, and does this product need them?"


Common misconceptions

  1. "Virtualization is strictly an optimisation." It removes find-in-page, breaks text selection across rows, breaks printing, and puts screen-reader navigation and browser scroll restoration on you. Those are functional regressions, not performance costs.

  2. "content-visibility made virtualization obsolete." It skips layout and paint but keeps nodes in the DOM, so it does not address DOM size or memory at 100,000 rows. At 2,000 it often is the better answer, and that is the honest scope of the claim.

  3. "Cache row heights by index." Indices shift. Cache by item id, or measurements silently attach to the wrong rows after a sort.

  4. "A running total of heights is fine." O(n) recomputation per measurement. At 100,000 rows a prefix-sum structure is the difference between smooth and unusable.

  5. "Update the window in a scroll handler." Scroll is compositor-driven (bi-10); a main-thread handler is at least one frame behind, and blank rows are the visible result.

  6. "Overscan is wasted work." Zero overscan produces blank rows on fast scroll — the app-level analogue of checkerboarding. It is a tunable trade, not waste.

  7. "Recycled rows are free." Recycle incorrectly and the DOM grows silently; it shows up in a heap snapshot as fe-02's detached-node shape.

  8. "Screen readers just see fewer rows." Without aria-setsize/aria-posinset and a correct grid role, AT is told the list has 20 items. That is a false statement about your data.


Interview talking points

  • "Virtualization is the clearest case where browser internals determine application architecture. You can't design it without knowing what DOM size costs, what layout costs, and that scrolling is compositor-driven — which is why a scroll-handler-based window update is always a frame behind."
  • "The whole difficulty reduces to one circular dependency: you can't know total height without measuring every row, and you can't measure a row without rendering it. Estimate-then-correct is the resolution, and the correction step — adjusting scroll when an above-viewport measurement resolves — is what separates a working virtualizer from a demo. It's scroll anchoring by hand, which is why the browser built its own."
  • "The part tutorials omit is that virtualization isn't free. You lose find-in-page, text selection across rows, printing, and you take on screen-reader semantics and scroll restoration. An engineer who reaches for it by default hasn't priced that side. A data grid for analysts, yes; a documentation page, no."
  • "Before writing a virtualizer I'd try content-visibility: auto with contain-intrinsic-size — we measured a 98% cut in initial layout from that one declaration, and it keeps every row in the DOM so accessibility and find-in-page still work. It doesn't reduce DOM size, so it's not a substitute at 100,000 rows, but at 2,000 it usually is."
  • "Measurement caching has to be keyed by item id, never index — indices shift on sort and the failure is silent. It's the same identity argument as list keys in a reconciler, and it fails the same way."
  • "aria-setsize is the runtime being told something it can't observe — that this is item 4,207 of 100,000 when only 20 exist. That's a pattern I collect: key, {passive:true}, contain, sideEffects, aria-setsize. Every time, static analysis is provably insufficient and the platform adds a way for the author to promise what the tool can't derive."

5. Principal Engineer Review

  1. Explain why virtualization helps, in terms of which pipeline stages it removes work from.
  2. Dynamic measurement forces layout. Design the scheme that minimises it; state what you give up.
  3. Argue that content-visibility: auto makes list virtualization obsolete. Then defeat it.
  4. What does virtualization do to accessibility and find-in-page, and what is your mitigation?
  5. A 100k-row grid must support sort, filter, and inline edit. Where does virtualization stop being the hard part?

Connections to other modules

  • bi-04 — the DOM cost model that makes virtualization worth doing at all.
  • bi-08 — layout cost, and the browser's own scroll anchoring, which you are reimplementing.
  • bi-09 — transform versus top for positioning the window; the property-tree argument applied.
  • bi-10 — why scroll is compositor-driven, and why blank rows are the app-level analogue of checkerboarding.
  • fw-02 — identity by key, not position; the same argument, applied to measurement caching.
  • fw-08 — scroll restoration; the same "cannot restore a position in a document of unknown size" problem.
  • fw-09 — a virtualized list over a paginated query is where both modules' edge cases meet.
  • fe-01 — forced synchronous layout: measurement per row per frame is that bug at scale.
  • fe-05 — content-visibility: auto measured at 98% off initial layout; the declarative alternative to consider first.
  • fe-04 — the accessibility semantics you take on when rows stop existing.