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.
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:
| Breaks | Mitigation |
|---|---|
| Find-in-page (Ctrl+F) | none reliable — a genuine loss |
| Screen reader navigation | aria-setsize / aria-posinset and a real grid role |
Anchor links / scrollIntoView | route through your own index → scroll |
| Tab order | manage focus on recycle; never leave focus on a removed node |
| Text selection across rows | rarely fixable |
| render an unvirtualized print view | |
| Browser scroll restoration | manual 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
| Capability | Evidence you have it |
|---|---|
| Justify virtualization | Name which pipeline stages it removes work from, and what it costs |
| Design the measurement scheme | Estimate, cache by id, prefix-sum, correct the anchor |
| Avoid measurement thrash | Reach for ResizeObserver and batched reads before getBoundingClientRect |
| Decide against it | Recognise 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:
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?
Core terminology
| Term | Definition |
|---|---|
| Window | The set of rows actually present in the DOM |
| Overscan | Extra rows rendered outside the viewport to absorb fast scrolling |
| Logical row | An item in the data; may or may not currently exist in the DOM |
| Estimated height | The assumed size of an unmeasured row |
| Measurement cache | Row heights keyed by item id, not index — indices shift |
| Prefix sum / Fenwick tree | O(log n) offset lookup and update over row heights |
| Scroll anchoring | Correcting scroll offset when an above-viewport measurement changes |
| Anchor drift | The jump that happens without it, as estimates resolve into real heights |
| Recycling | Reusing row DOM nodes for different data as the window moves |
| Checkerboarding | The compositor analogue of blank rows: unrasterised tiles (bi-10) |
aria-setsize / aria-posinset | Telling AT "item 4,207 of 100,000" when only 20 exist |
content-visibility: auto | Skip layout/paint for offscreen content while keeping it in the DOM |
contain-intrinsic-size | The placeholder size that stops the scrollbar lying |
| Sentinel | An 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:
ResizeObserver— delivered inside the rendering steps, after layout, so it does not force a synchronous flush.- Batch reads, then writes — measure everything, then apply all offsets.
IntersectionObserverfor visibility rather than repeatedgetBoundingClientRect.content-visibility: autowithcontain-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
-
"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.
-
"
content-visibilitymade 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. -
"Cache row heights by index." Indices shift. Cache by item id, or measurements silently attach to the wrong rows after a sort.
-
"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.
-
"Update the window in a
scrollhandler." Scroll is compositor-driven (bi-10); a main-thread handler is at least one frame behind, and blank rows are the visible result. -
"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.
-
"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. -
"Screen readers just see fewer rows." Without
aria-setsize/aria-posinsetand 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: autowithcontain-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-setsizeis 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
- 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?
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—transformversustopfor 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: automeasured at 98% off initial layout; the declarative alternative to consider first.fe-04— the accessibility semantics you take on when rows stop existing.