Design a virtualized list
A reusable list that can hold 100,000 rows but only keeps the few dozen near the screen in the page. The idea fits in one line; the hard parts are the offset math, rows whose height is unknown until they render, keeping the reader's place while sizes change and older items load above, fast flings, browser height limits, and what screen readers, keyboards, and find-in-page lose.
The question
Our design system needs one list component for very long lists. Three teams are waiting: chat needs a channel history of up to 50,000 messages that loads older messages above, the CI team needs a log viewer with 200,000 lines, and the admin tool lists 100,000 users.
Design it. How does it decide what to draw, how does it handle rows whose height it can't know in advance, how does it keep the reader's place, and what does it give up? It must scroll smoothly on a mid-range Android phone and work with a keyboard and a screen reader.
Real-life examples: TanStack Virtual, react-window, and react-virtuoso, Slack and Discord message history, Log viewers in CI tools such as GitHub Actions, Admin tables with 100,000 users
Requirements: what to ask before drawing anything
A virtualized list (also called a windowed list) is a scrolling list that pretends to contain every row but only puts the rows near the visible area into the page. Scroll down and rows that leave the top are removed while rows entering at the bottom are created. The reader sees a 100,000 row list with a normal scrollbar. The browser sees about 30 rows.
This is a component question. You are not designing the chat product or the log pipeline. You are designing the piece a design system ships so that three very different teams can use it. That moves the weight toward correct math, good defaults, a small API that covers chat, logs, and tables, and honest answers about what virtualization breaks.
Start with questions. Each answer below changes something in the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| How many rows? | Users: 100,000. Logs: 200,000 lines, sometimes 2 million. Chat: up to 50,000 loaded messages. | Millions of rows push us toward the browser's maximum element height. |
| Do rows have a fixed height? | Log lines and user rows: yes, 20 px and 48 px. Chat messages: no, 40 px for "ok" and 600 px for a code block with an image. | We need a fast fixed path and a measured path for unknown heights. |
| Is all data in memory? | Users and logs: the count is known, rows arrive in pages. Chat: the total is unknown and older pages load above. | The list reports its visible range; the page decides what to fetch. |
| Does it scroll inside a box or with the window? | Chat and logs scroll in a panel. The admin list scrolls with the window under a sticky header. | The scroll source and an offset for content above the list are inputs. |
| Which way does it grow? | Logs and users grow at the bottom. Chat starts at the bottom and grows in both directions. | Reverse mode and prepending without a jump. |
| Who uses keyboards and screen readers? | All three lists must be fully usable with both. | Position announcements, focus that survives scrolling, a search for unrendered rows. |
| Which device sets the bar? | A mid-range Android phone with a main thread about 4 to 6 times slower than a laptop's. | About 16 ms per frame at 60 Hz for everything, so per-row work must be tiny. |
| Is it rendered on the server? | The admin page is server-rendered; chat and logs are not. | The first window must render on the server without measuring anything. |
Functional requirements
- Render lists from 0 to 2,000,000 rows, keeping only the visible rows plus a small buffer in the DOM.
- Support fixed heights, known per-row heights, and unknown heights that are measured after render, including rows that grow later when an image loads.
- Keep what the reader is looking at still: when rows above are measured, when rows are prepended, and when the window resizes.
- Offer
scrollToIndexandscrollToOffset, with alignment (start, center, end, auto), even for rows never rendered. - Report the visible range so the page can fetch more data, and support a reverse mode for chat that starts at the bottom and follows new messages.
- Work with vertical lists, horizontal lists, and both axes for grids, with optional sticky group headers.
Non-functional requirements
- Scrolling holds 60 frames per second on the reference phone: under about 8 ms of script per frame, leaving the rest for style, layout, and paint.
- DOM size stays flat: under about 1,000 elements inside the list whatever the row count, so memory doesn't grow with the data.
- First render of a 100,000 row list under 50 ms of script on a laptop, because only the first window is built.
- No visible jumps: content under the reader's eyes moves at most 1 px when sizes are corrected.
- Accessible: each row announces its position in the full list ("row 4,801 of 100,000"), focus is never lost when the focused row scrolls away, and every row is reachable by keyboard.
- Headless core: the math is plain TypeScript with no DOM, testable with numbers alone, and usable from React, Vue, or plain DOM.
Out of scope, and why
- Fetching and paging data: the infinite scroll guide owns that. The list only reports which indexes are on screen. Keeping fetching out is what makes the component reusable across three teams.
- Table features such as sorting, column resizing, and cell editing: the data table guide builds those on top of this list, including column virtualization.
- Drag and drop reordering: the kanban board guide covers dragging inside virtualized columns and the auto-scroll it needs.
- Masonry layouts, where rows are placed in several columns by height: they need a different layout engine.
Check your understanding · Exercise 1
Which answers from the interviewer change the design of the list itself? Select all that apply.
What makes this hard, and whether you need it at all
Four facts about these lists drive every later decision. They are long: tens of thousands of rows, sometimes millions. They are uneven: rows differ in height and some change height after they appear. They move: rows load above and below while the reader is looking.
Finally, they are read by more than eyes: screen readers, keyboards, and the browser's find-in-page all expect every row to exist in the page. Virtualization fights the last fact on purpose, so it has to pay that back.
What 100,000 real rows cost
Take the admin list. One user row has an avatar, a name, an email, a role badge, and a menu button: about 8 elements with wrappers. 100,000 rows is 800,000 elements. Each element costs memory as a DOM node, again as a computed style and a layout object, and again as a React fiber if React built it.
The numbers below are rough estimates for a laptop, not measurements to quote; the shape is what matters.
| Rows rendered | Elements | First render (estimate) | What the user feels |
|---|---|---|---|
| 100 | about 800 | a few ms | Nothing. This is a normal page. |
| 1,000 | about 8,000 | 50 to 150 ms | A small hitch on load; fine on a laptop, noticeable on a phone. |
| 10,000 | about 80,000 | 0.5 to 2 s | Long tasks, slow typing in any search box, janky hover effects. |
| 100,000 | about 800,000 | several seconds or a crashed tab | The page freezes; phones may kill the tab for memory. |
| 30 (virtualized) | about 240 | under 10 ms | Same as 100 rows, whatever the total. |
Three costs grow with the element count. Creation: the script that builds the nodes, run once but all at once. Memory: it stays for as long as the list is open. Style and layout: any change that touches an ancestor, like toggling a dark mode class or resizing the window, makes the browser recompute styles and layout across the whole tree (the reflow-repaint-composite lesson explains when each step runs).
At 800,000 elements a single class change can take hundreds of milliseconds. Lighthouse's old DOM size audit failed pages with more than about 1,400 elements for this reason. Newer Lighthouse versions look for slow style and layout work instead, but 1,400 is still a handy guideline.
Decision How do we show a list with 100,000 rows?
What we pick: Use windowing for lists that can pass a few thousand rows, which all three teams have. Keep content-visibility: auto as the recommended cheap tool for long pages with hundreds of heavy sections (articles, settings pages, a comments thread of 300 items), where keeping find-in-page matters more than memory. They are not rivals: windowing bounds the node count, content-visibility cuts the cost of the nodes you keep.
Where content-visibility stops helping
content-visibility: auto is Baseline across current browsers since Safari 18 shipped it in 2024. With contain-intrinsic-size: auto 72px, an off-screen row reserves 72 px until it has been rendered once, and then the browser remembers its real size. That gives a fair scrollbar without any script. One caveat: Safari 18 to 25 could not find text in skipped rows with find-in-page; Safari 26 fixed it.
The catch is in the word skip: the browser skips rendering work for the row, not its existence. The DOM node, its event listeners, its React fiber, and its images all remain. For 300 heavy sections that's fine. For 100,000 rows the creation cost and memory alone sink it.
Think first
A team has a settings page with 120 sections, each a form with 20 inputs. First load is slow. Should they use the virtualized list?
Probably not. 120 sections is 2,400 inputs: heavy, but a fixed and bounded number. content-visibility: auto on each section cuts the layout and paint cost and keeps find-in-page, browser autofill, and form validation working on every input.
Virtualizing a form is risky: unmounting an input loses its uncontrolled value, its focus, and its validation message unless all state lives outside. Reach for windowing when the count is large or unbounded, not just because a page is slow.
Check your understanding · Exercise 2
Which statements about content-visibility: auto are true? Select all that apply.
The core math: from scrollTop to a range of rows
Everything starts from three numbers the browser gives us. scrollTop is how many pixels of the list have scrolled above the top edge of the scroll box. clientHeight is the height of the visible area, the viewport. And for every row we need its offset: the distance from the top of the list to the top of that row.
The job is to find the rows whose box overlaps the band from scrollTop to scrollTop + clientHeight.
Fixed heights: one division
If every row is h px tall, row i starts at i * h and the math is a division. The first visible index is floor(scrollTop / h) and the last is ceil((scrollTop + clientHeight) / h) - 1, clamped to the row count.
Worked example with the admin list: 100,000 rows of 48 px, a 720 px viewport, scrolled to scrollTop = 96,000. First is floor(96000 / 48) = 2000. Last is ceil(96720 / 48) - 1 = 2014. So 15 rows are visible, the spacer is 100000 * 48 = 4,800,000 px tall, and the work is the same at row 2,000 as at row 99,000.
Variable heights: prefix sums and binary search
When rows differ, keep an array of sizes and a second array of offsets, where offsets[i] is the sum of all sizes before row i. This running total is called a prefix sum. It has count + 1 entries; the last one is the total height of the list.
Offsets only ever grow, so the array is sorted, and a sorted array can be searched with binary search: halve the range on each step. For 100,000 rows that is at most 17 steps to find the first row whose bottom is below scrollTop. Then walk forward, adding sizes, until you pass the bottom of the viewport.
offsets[i] is the sum of every size before row i, so the array is sorted and binary search works. 100,000 rows need at most 17 steps.// Largest i with offsets[i] <= y: the row that contains pixel y.
function rowAt(offsets: Float64Array, count: number, y: number): number {
let lo = 0;
let hi = count - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (offsets[mid] <= y) lo = mid;
else hi = mid - 1;
}
return lo;
}
export function getRange(
offsets: Float64Array, count: number,
scrollTop: number, viewport: number, overscan: number,
) {
if (count === 0) return { start: 0, end: -1, first: 0, last: -1 };
const first = rowAt(offsets, count, scrollTop);
let last = first;
const bottom = scrollTop + viewport;
while (last < count - 1 && offsets[last + 1] < bottom) last++;
return {
first, last, // visible rows, for aria and onRangeChange
start: Math.max(0, first - overscan), // rendered rows
end: Math.min(count - 1, last + overscan),
};
}
Decision How do we store offsets when row sizes keep changing as they are measured?
What we pick: Use the prefix array with a lazy rebuild from the lowest dirty index. TanStack Virtual works this way, with a binary search over its measurements (react-virtuoso keeps a balanced tree of size ranges instead). It is fast enough for 100,000 rows, and its batching fits the once-per-frame update loop we build later. Mention the Fenwick tree as the upgrade if profiling shows rebuilds of millions of rows on every measurement.
Overscan: rows you render but nobody sees yet
If we render exactly the visible rows, the reader sees blank space at the edge during scrolling. The browser scrolls on its own compositor thread (a separate thread that moves already painted layers), so the content moves before our script has run for that frame. Overscan renders a few extra rows above and below the visible range, so there is something painted to scroll into.
Library defaults are small: TanStack Virtual renders 1 extra row on each side and react-window (version 2) renders 3, while react-virtuoso measures its overscan in pixels. Chat and log viewers usually want more, often 3 to 10 rows or a pixel budget such as half a viewport. A pixel budget is the better unit when rows range from 20 px to 600 px.
- More overscan means fewer blank flashes but more rows to render each time the range shifts, and a larger DOM.
- Directional overscan puts most of the extra rows on the side the reader is scrolling toward: 8 below and 2 above while scrolling down.
- Re-render only when
startorendchanges. Scrolling 10 px inside the same rows must not re-render the list.
Check your understanding · Exercise 3
Fixed rows of 20 px (a log viewer), 2,000 rows, viewport 400 px, scrollTop = 1,010, overscan 3. Which statements are true? Select all that apply.
The DOM structure: a tall spacer and a few placed rows
The math gives a range and an offset per row. Now the browser has to believe the list is 4,800,000 px tall so the scrollbar thumb has the right size and dragging it to the middle lands on row 50,000. Three elements do it: the scroll box with a fixed height and overflow: auto, a spacer inside it whose height is the total size of all rows, and the rendered rows placed at their true offsets inside the spacer.
Decision How do we place the rendered rows at their true offsets?
What we pick: Default to absolute rows with translateY: it is what TanStack Virtual's examples use, it keeps rows independent, and it extends to the grid case. Offer padding or a translated wrapper as a mode for semantic tables and lists that need normal flow. Because corrections run in a ResizeObserver callback, which fires after layout but before paint, the overlap in the first option is never painted when measurement is wired correctly.
<div ref={scrollRef} style={{ height: 720, overflow: "auto", overflowAnchor: "none" }}>
<div style={{ height: totalSize, position: "relative" }}>
{items.map((item) => (
<div
key={item.key}
data-index={item.index}
ref={measureRow}
style={{
position: "absolute", top: 0, left: 0, width: "100%",
transform: `translateY(${item.start}px)`,
}}
>
{renderItem(item.index)}
</div>
))}
</div>
</div>
overflowAnchor: "none" turns off the browser's own scroll anchoring for the box. We do our own anchoring in the next chapter, and two systems correcting the same scroll position at once produce a double jump.
Sticky group headers inside a virtualized list
Chat shows a sticky date header ("Yesterday") and the admin list groups users by team. position: sticky keeps an element pinned while its containing block (its parent's box) is on screen. If the header is one absolutely positioned row among others, its parent is the spacer, so it would stick for the whole list, and as soon as it scrolls out of the rendered range it is unmounted and vanishes. Two changes fix it:
- Treat headers as rows of their own in the item array, so the math places them like any row.
- Always keep the active header mounted: when computing the range, add the index of the last header at or above
first, even if it is outside the overscan. Render that one header withposition: sticky; top: 0and az-indexabove rows, and the others normally.
function withStickyHeader(range: Range, headerIndexes: number[]): number[] {
// last header at or before the first visible row
let lo = 0, hi = headerIndexes.length - 1, active = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (headerIndexes[mid] <= range.first) { active = headerIndexes[mid]; lo = mid + 1; }
else hi = mid - 1;
}
const rows: number[] = [];
if (active !== -1 && active < range.start) rows.push(active);
for (let i = range.start; i <= range.end; i++) rows.push(i);
return rows;
}
Check your understanding · Exercise 4
Why does the spacer exist, instead of just rendering the visible rows in a short box? Select all that apply.
Unknown heights: estimate, measure, correct, and don't jump
A chat message's height depends on its text length, the font, the container width, whether it has an image, and whether the image has loaded. We can't know it before the browser lays it out, but the math needs every row's size to compute offsets and the total height. The answer has three steps: estimate every row, measure the ones that render, and correct the offsets without moving what the reader is looking at.
Step 1: estimate
estimateSize(index) returns a guess for rows not yet measured. A constant (56 px for every message) works, but the closer the guess, the smaller the later corrections. Good estimates use what we know without rendering: 56 px for a text message, 56 plus the image's aspect-ratio height for an image message (the server sends width and height), 24 px for a date header. Some lists use the running average of measured rows for unmeasured ones, which makes the scrollbar settle faster on long sessions.
Step 2: measure with ResizeObserver
When a row mounts, observe it with one shared ResizeObserver. Its callback receives the row's borderBoxSize, and it fires again whenever the row changes size later: an image loads, a web font swaps in, the reader expands a stack trace in the log viewer. Reading offsetHeight once in a layout effect would catch the first size only. ResizeObserver callbacks run after layout and before paint, so a correction made inside the callback appears in the same frame the reader would have seen the wrong layout.
Store measurements by item key, not by index. In chat, prepending 50 older messages shifts every index by 50. A cache keyed by index would hand message A's height to message B. Keyed by message id, the cache stays correct and the offsets are rebuilt from it.
Step 3: correct offsets and keep the reader's place
Here is the bug every first version has. The reader scrolls up into rows that were estimated at 56 px. Row 4,000 renders and measures 140 px. Every offset below row 4,000 grows by 84 px, including the rows on screen.
The browser keeps scrollTop where it was, so the content the reader was looking at slides down 84 px. Scroll up through 20 such rows and the text jumps around like a broken video.
The fix is scroll anchoring: pick an anchor row, the first visible row, and remember how far its top is from the top of the viewport. After applying measurements and rebuilding offsets, set scrollTop so the anchor row's top is at the same distance again. Changes below the anchor move nothing on screen. Changes above it are absorbed by moving scrollTop by the same amount, which the reader can't see because the content under their eyes stays put.
const ro = new ResizeObserver((entries) => {
const el = scrollEl;
const anchor = core.rowAt(el.scrollTop); // first visible row
const anchorGap = core.offsetOf(anchor) - el.scrollTop;
let changed = false;
for (const entry of entries) {
const index = Number((entry.target as HTMLElement).dataset.index);
const size = entry.borderBoxSize[0].blockSize;
if (core.setSize(index, size)) changed = true; // false if unchanged
}
if (!changed) return;
core.rebuildOffsets(); // from lowest dirty row
const target = core.offsetOf(anchor) - anchorGap;
if (Math.abs(target - el.scrollTop) >= 0.5) el.scrollTop = target;
scheduleRender(); // new total size, new range
});
Decision Who keeps the reader's place when rows above change size?
What we pick: The list anchors itself. It is the only option that also handles prepending and resizes the same way. For the fling problem, follow what libraries do: while the user is actively scrolling up, apply corrections for rows above the viewport in one batch when scrolling pauses, or adjust only rows that enter the viewport from above.
Images that load late, and width changes
- Reserve image space before it loads:
widthandheightattributes oraspect-ratiofrom server metadata. Then the first measurement is already correct and the late load changes nothing. - When metadata is missing,
ResizeObservercatches the growth and anchoring absorbs it. The reader sees the image appear below their anchor, never a jump. - When the container width changes (window resize, sidebar toggle), text rows rewrap and every cached height is wrong. Drop measurements for unmounted rows back to estimates, keep the anchor, and let mounted rows re-measure. Don't try to scale old heights by the width ratio: wrapping isn't linear.
- Never change the size of an observed row from inside the observer callback. The browser delivers that new size in a later frame instead, and logs the console error "ResizeObserver loop completed with undelivered notifications". Changing
scrollTopis fine: it doesn't resize anything.
Check your understanding · Exercise 5
The anchor row is 3,010, the first visible row; its top is 40 px above the viewport top, partly scrolled out of view. Rows 3,000 to 3,009 were estimated at 56 px and now measure 80 px each. Row 3,020 also grew by 30 px. What should happen? Select all that apply.
Scrolling performance: staying inside 16 ms
At 60 Hz the browser has about 16.7 ms per frame for everything: our script, style, layout, paint, and compositing. On the reference phone, aim to spend under 8 ms in script. A virtual list does work on scroll by design, so the question is how little work each scroll frame can cost. The runtime-performance lesson covers long tasks and profiling; here we apply it to one loop.
The scroll loop
- The compositor thread moves the already painted content as soon as the finger or wheel moves. This happens even if the main thread is busy, which is why scrolling feels smooth on heavy pages and also why blank areas can appear: the compositor scrolls into rows we haven't rendered yet.
- On the main thread, the browser fires
scrollat most once per frame, as part of updating the rendering. ReadscrollTopthere; it is cheap because layout is already clean. - Compute the new range with the binary search (microseconds). If
startandendare unchanged, stop. Most scroll frames end here. - If the range changed, render the new rows. Rows that stay in the range must not re-render: stable keys and memoized row components make the update cost proportional to the rows that entered.
- Layout runs,
ResizeObserverreports the new rows' sizes, the anchor correction runs if needed, and the frame paints.
let frame = 0;
el.addEventListener("scroll", () => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
const next = core.getRange(el.scrollTop, el.clientHeight);
if (next.start !== range.start || next.end !== range.end) {
range = next;
notify(); // React: setState; plain DOM: patch rows
}
});
}, { passive: true });
Do we need requestAnimationFrame if scroll already fires once per frame? It is a cheap guard: other code can also call scrollTo, and resize and measurement callbacks also want a render. Funneling them all into one frame callback means one range computation and one render per frame, whatever triggered it. { passive: true } changes nothing for scroll itself, which can't be cancelled, but wheel and touchmove listeners on the list (for example to detect user scrolling) must be passive, or the browser waits for them before scrolling.
Fast flings and blank areas
A hard fling on a phone can move several thousand pixels per second, which is 50 to 100 px or more per frame. If rendering the new rows takes 30 ms, the compositor has already scrolled two frames past them and the reader sees empty spacer. Three tools, used together:
- Overscan in the scroll direction, sized in pixels: half a viewport ahead covers moderate speeds.
- Cheap rows while flinging: track scroll velocity; above a threshold (say 3,000 px per second) render a lightweight placeholder per row (a grey bar of the right height) and swap in real content when velocity drops. react-virtuoso ships this idea as a scroll seek mode. Placeholders keep the right sizes, so no correction happens.
- Rows that render in under 1 ms: no data fetching or heavy formatting inside a row. Format dates and highlight log syntax once per item and cache the result by key.
Decision What key does each rendered row get?
What we pick: Key by item id, always. Use slot recycling only inside a specialized, stateless row such as a plain log line, and only if profiling shows mount cost dominates.
will-change and layer costs
It is tempting to add will-change: transform to every row because rows are moved with transforms. Don't. It asks the browser to give each row its own compositor layer, a separate bitmap in GPU memory. A 1,200 by 600 px row is about 2.9 MB at 4 bytes per pixel; 40 of them is over 100 MB on a phone.
The scroll box is already composited as a whole by the browser, which is what makes scrolling smooth. Rows only change position when the range changes, which is rare, so per-row layers buy nothing.
Check your understanding · Exercise 6
Profiling shows each range change takes 25 ms on the reference phone and readers see blank areas when flinging. Which changes help? Select all that apply.
Reverse and bidirectional lists: chat starts at the bottom
Chat breaks two assumptions of a normal list. It opens at the end, with the newest message at the bottom of the viewport. And it grows at both ends: older history is prepended above as the reader scrolls up, new messages are appended below. The Slack and live comments guides decide when to fetch and how messages arrive; this chapter is about what the list must do so neither change moves the text under the reader's eyes.
Decision How do we build a list that starts at the bottom and prepends history?
What we pick: A normal list plus anchoring, with measurements and the anchor stored by item key. Keys give us what stable indexes give, without asking callers to manage a starting index. The data change is detected by comparing the first and last keys before and after: a new first key means a prepend, a new last key means an append.
m_9001 and its gap, captured before the new rows render. On failure nothing is inserted, so there is nothing to correct.Prepending 50 messages without a jump
- The reader scrolls up; the list reports
first = 6. The page sees it is within 10 rows of the top and fetches the previous page. A loading row shows above message 0. - Before the new data renders, the list records the anchor: the key of the first visible message (
m_9001) and its gap from the viewport top (12 px). - The page passes 50 older messages plus the old ones. The list sees the first key changed, so it is a prepend. Offsets are rebuilt; the 50 new rows use estimates.
- In the same layout pass, before paint, the list sets
scrollTop = offsetOf(m_9001) - 12. With 50 estimated rows of 56 px, that is about 2,800 px further down. The reader sees nothing move. - Rows near the anchor render and get measured. Their corrections are above the anchor, so the normal anchoring from the measurement chapter absorbs them.
In React, the anchor restore must run in a layout effect (or in the virtualizer before the browser paints), not a normal effect. A normal effect runs after paint, so the reader would see one frame with the content shifted 2,800 px, then a snap back.
Following new messages at the bottom
- The reader is at the bottom when
scrollHeight - scrollTop - clientHeightis under a small threshold such as 8 px. Measure it before applying new data, not after: after an append the reader is no longer at the bottom by definition. - If they were at the bottom, scroll to the new last row aligned to the end once it has been measured, and keep following while its image loads.
- If they were reading history, don't move. Show a "3 new messages" button that jumps to the bottom, matching the live comments guide.
- Own messages are the exception: when the reader sends a message, always scroll to the bottom, even if they were reading history.
Check your understanding · Exercise 7
Users report that loading older messages makes the view jump up by a few hundred pixels, then settle. Which causes are likely? Select all that apply.
Scroll to any row, millions of rows, and two axes
The log viewer links to line 150,000 from an error summary. Search in chat jumps to a message from last month. Both need scrollToIndex(i, { align }), where align is start, center, end, or auto (scroll only if the row isn't fully visible, to the nearest edge).
With fixed heights it is one assignment: scrollTop = i * h, adjusted for alignment. With unknown heights the target offset is a guess, because every row above it may be estimated.
Estimate, scroll, measure, correct
- Compute the target from current offsets: for
start,offsetOf(i); forcenter,offsetOf(i) - (viewport - sizeOf(i)) / 2. - Set
scrollTopto it, instantly. The rows around the target render with estimated positions. - They are measured. If any of them, or the target itself, changed size, the target offset moved. Anchoring keeps the content still, but the target may now be 30 px off its alignment.
- On the next frame, recompute the target and scroll again. Stop when two frames in a row produce the same offset (within 1 px), or after a cap such as 10 frames.
It converges fast because only the rows near the target matter: once they are measured, their offsets relative to each other are exact. Errors in the 149,000 rows above only shift the absolute number, which the next scroll absorbs. The reader sees one jump and at most a 1 or 2 frame settle.
If the user scrolls by wheel or touch while the loop is correcting, cancel it. Fighting the user's input is worse than landing 30 px off. Detect it by comparing scrollTop at the start of each frame with the value the loop last wrote.
When the list is taller than the browser allows
Browsers store layout positions in fixed-point numbers, so elements have a maximum height, and the limits differ by engine and version. Recent measurements put it at about 16.7 million px for scroll content in Chromium (even though a computed height can read about 33.5 million), about 17.9 million px in Firefox, and about 33.5 million px in Safari. 2 million log lines at 20 px is 40 million px, over every limit.
Past the limit the spacer is silently clipped, and the last rows can't be reached. Design for a safe budget of about 15 million px, under the smallest of these limits.
k only for large jumps.Decision How do we show 2 million rows when the spacer can't exceed about 15 million px?
What we pick: Ship the scaled scroll space as an opt-in mode that turns on only when the total passes the budget, with wheel and keyboard deltas applied 1:1 in script. The admin list and chat never reach it; the log viewer does. If the log team later needs pixel-perfect behavior at 20 million lines, that is the point to move to a custom scrollbar, the way code editors do.
Horizontal lists and grids
Nothing in the core is vertical. Swap scrollTop for scrollLeft, height for width, and translateY for translateX, and the same math runs a horizontal carousel of 5,000 thumbnails.
A grid is two virtualizers: one for rows driven by scrollTop, one for columns driven by scrollLeft, rendering the cross product of the two ranges. A 100,000 by 200 grid with 30 rows and 12 columns visible renders 360 cells, not 20 million. The data table guide builds column virtualization, pinned columns, and header sync on top of exactly this split, so here it is enough that the core takes an axis option and a grid composes two instances.
Check your understanding · Exercise 8
scrollToIndex(150000, { align: "center" }) on chat-like rows with unknown heights lands 40 px off. Which behaviors are correct? Select all that apply.
Architecture: a headless core and thin bindings
We now know the moving parts: offset math, measurement, anchoring, a frame loop, a scroll-to loop, reverse mode. Where does each live? The tempting design is one big React component that does all of it. It is hard to test (every test needs a DOM with real layout), it ties the logic to React, and teams who want a grid or a horizontal list have to fork it.
Split it instead, the way TanStack Virtual does: a headless core that is plain TypeScript, a small DOM adapter, and framework bindings on top.
| Part | Responsibility | Knows about |
|---|---|---|
| Virtualizer core | Sizes by key, the prefix-sum offsets, range with overscan, anchor capture and restore, the scroll-to loop, scale factor for huge lists. | Numbers only. No DOM, no React. Unit tested with plain arrays. |
| Scroll adapter | Reads scrollTop and viewport size from an element or the window, writes scrollTop, listens to scroll passively, observes the viewport with ResizeObserver. | One scroll source. A window adapter adds scrollMargin for content above the list. |
| Row measurer | One shared ResizeObserver for all mounted rows; maps each entry to an index and key, hands sizes to the core in one batch. | The DOM rows, via data-index. |
| Frame scheduler | Coalesces scroll, resize, measurement, and data changes into one requestAnimationFrame per frame. | Timing only. |
useVirtualizer hook | Creates the core and adapter, subscribes to range changes, returns virtual items, total size, a measure ref, and scrollToIndex. | React state and refs. |
VirtualList component | The ready-made list: markup, ARIA attributes, focus retention, sticky headers, reverse mode, placeholders while flinging. | Accessibility and product defaults. |
| The page | Owns the data, fetches pages when onRangeChange says the reader is near an edge, renders each row. | Its API and its items. The list never fetches. |
Why these boundaries? The core is where the bugs are (off-by-one ranges, wrong anchors), so it must be testable without a browser: feed it sizes and a scroll position, assert the range. The adapter is the only place that touches scroll APIs, so supporting the window instead of a box is one new adapter, not a fork.
The hook exists for teams that need custom markup, like the data table; the component exists so the other 90% of uses get accessibility and reverse mode right by default. And fetching stays outside, because chat, logs, and the admin list fetch in three different ways.
One frame through the parts
- The adapter's scroll listener fires and asks the scheduler for a frame.
- In the frame callback the core computes the range from the new
scrollTop. Unchanged: done. - Changed: the hook sets state, React renders the rows that entered, and unmounts the ones that left.
- The measurer observes new rows. Its callback, after layout, sends sizes to the core; the core rebuilds offsets from the lowest dirty row, restores the anchor through the adapter, and asks for another frame if the total size changed.
- The core emits
onRangeChange({ first, last })at most once per frame; the page may start a fetch, and new data flows back down as props.
Check your understanding · Exercise 9
Which responsibilities belong in the headless core rather than the React component? Select all that apply.
Data model and the component API
The list holds very little data of its own: it never owns the items. What it owns is geometry: sizes, offsets, and where the reader is. Getting the keys of that geometry right is what made prepending and measurement work in earlier chapters.
type Key = string | number;
type VirtualItem = {
index: number; // position in the current array
key: Key; // from getItemKey(index), stable across prepends
start: number; // offset from the top of the list, in px
size: number; // measured, or estimated if never rendered
};
type CoreState = {
count: number;
measured: Map<Key, number>; // real sizes, by key
offsets: Float64Array; // count + 1 entries
dirtyFrom: number; // lowest index whose offset is stale
scrollOffset: number; // last scrollTop read or written
viewportSize: number;
range: { start: number; end: number; first: number; last: number };
anchor: { key: Key; gap: number } | null;
scrollTarget: { index: number; align: Align; tries: number } | null;
isScrolling: boolean;
};
| Data | Lives in | Why there |
|---|---|---|
| Items (messages, users, log lines) | The page's store or server cache | The list only asks for a key and a rendered row by index. |
| Measured sizes | Core, a Map keyed by item key | Survives prepends and range changes. The page may keep it across navigations to restore Back without re-measuring. |
| Offsets | Core, a Float64Array | Derived from sizes; rebuilt lazily from dirtyFrom. |
| Scroll position | The DOM (scrollTop), mirrored in the core | The browser is the source of truth; the core reads it each frame. Save it with the anchor key in history state for Back. |
| Focused row key | The component | Needed to keep that row mounted (accessibility chapter). |
The interface comes next. Three teams with different markup will call it, so the first question is how much of the markup the list should own. Chat and the admin list want a ready list with accessibility built in. The data table team needs table rows and two axes, which a fixed component can't give them.
Decision Do we ship a headless hook, a component, or both?
What we pick: Both, layered. The component-api-design lesson's rule applies: make the common case one component with good defaults, and expose the primitive underneath for the cases you can't predict. The data table team uses the hook; chat, logs, and the admin list use the component.
type VirtualListProps = {
count: number;
getItemKey: (index: number) => Key;
renderItem: (index: number, state: { isScrolling: boolean }) => ReactNode;
estimateSize: (index: number) => number; // or a number for every row
fixedSize?: number; // skips measurement entirely
overscan?: number; // in px, default half a viewport
horizontal?: boolean;
reverse?: boolean; // start at the end, follow appends at bottom
scrollElement?: "self" | "window";
scrollMargin?: number; // px of page content above the list
initialIndex?: number; // where to start, e.g. last read message
totalCount?: number; // for aria when count is a loaded slice
label: string; // accessible name of the list
onRangeChange?: (range: { first: number; last: number }) => void;
onAtEndChange?: (atEnd: boolean) => void;
placeholder?: (index: number) => ReactNode; // shown while flinging
};
type VirtualListHandle = {
scrollToIndex(index: number, opts?: { align?: Align; behavior?: "auto" | "smooth" }): void;
scrollToKey(key: Key, opts?: { align?: Align }): void;
scrollToOffset(px: number): void;
measure(): void; // drop cached sizes, e.g. after a font size change
};
getItemKeyis required, not optional. A default of the index would silently break prepends and focus retention, the two things hardest to debug later.overscanis in pixels, because a row count means different things for 20 px log lines and 600 px messages.fixedSizeis a separate prop fromestimateSizeso the fast path is explicit: no observer, no corrections.onRangeChangereports visible rows, not rendered rows, so the page's prefetch distance doesn't change when someone tunes overscan.
Check your understanding · Exercise 10
Which API choices protect callers from subtle bugs? Select all that apply.
Accessibility and find-in-page: paying back what we removed
Virtualization deletes rows from the page on purpose. Sighted mouse users never notice. Three other groups do: screen reader users, whose software reads the DOM; keyboard users, whose focus lives on a DOM element; and everyone who presses Ctrl+F, because the browser only searches the DOM.
Each needs a specific fix, and an interviewer at a senior level will ask about all three. The aria-patterns, keyboard-navigation, and focus-management lessons cover the general patterns; here is what changes when most rows don't exist.
Announcing positions in the full list
With 30 rows mounted, a screen reader may say "list, 30 items" and read the first mounted row, user 1,998, as "1 of 30". ARIA lets us state the real numbers: aria-setsize on each item is the full count, and aria-posinset is its 1-based position. For tables and grids the equivalents are aria-rowcount on the grid and aria-rowindex on each row. When the total is unknown, as in chat history, aria-setsize="-1" says so explicitly.
How much of this is spoken depends on the screen reader and browser pair. Positions such as "2,001 of 100,000" are widely supported; the list's own item count may still reflect the mounted rows in some pairs. Test with at least NVDA or JAWS on Windows and VoiceOver on macOS and iOS.
<div role="list" aria-label="Users" aria-describedby="list-hint">
<div role="listitem" aria-setsize="100000" aria-posinset="1998">…</div>
<div role="listitem" aria-setsize="100000" aria-posinset="1999">…</div>
<div role="listitem" aria-setsize="100000" aria-posinset="2000">…</div>
<!-- … about 30 items … -->
</div>
<p id="list-hint" hidden>Use the search box above to find a user.</p>
<!-- Log viewer: a grid, because lines have columns and arrow-key navigation -->
<div role="grid" aria-label="Build log" aria-rowcount="200000">
<div role="row" aria-rowindex="150000">…</div>
</div>
Pick the role by behavior. A plain list for the admin list, where each row holds a link and a menu. A grid for the log viewer, where arrow keys move between lines and cells. For chat, many products use a list or the feed pattern, whose items are article elements with aria-posinset and aria-setsize, and which sets aria-busy="true" on the feed while a page loads so the screen reader doesn't read half-inserted content.
aria-setsize and aria-posinset tell the screen reader where each one sits in the full list. The focused row is the one exception to the window.Never unmount the focused row
A keyboard user tabs to the menu button in row 2,000, then scrolls with the mouse wheel to row 2,300. Row 2,000 leaves the range and unmounts. The focused element is gone, so focus falls back to body. The next Tab starts from the top of the page, and a screen reader loses its place entirely.
The fix is the same trick as sticky headers: the range extractor always adds the focused row's index, so the focused row stays mounted at its true offset, off screen, until focus leaves it. That is one extra row in the DOM, at most.
- Track focus with
focusinandfocusouton the list, storing the focused row's key, so it survives prepends. - Arrow keys in a grid or listbox: call
scrollToIndex(next, { align: "auto" }), then focus the new row in a layout effect after it mounts. Never callfocus()on a row that doesn't exist yet. - Home and End jump to row 0 and the last row through the scroll-to loop, then move focus.
- Use a roving
tabindex(one row withtabindex="0", the rest-1) so Tab leaves the list in one press instead of stopping on every mounted row.
Find-in-page can't find what isn't there
Ctrl+F searches the DOM. Text in unrendered rows is not found, and the match count is wrong. hidden="until-found" doesn't help: it reveals hidden elements that exist, and our rows don't. There is no way to make native find search data that isn't in the page, so each list chooses one of three answers, and the guide's position is that the choice should be explicit, not discovered by a user.
| Approach | Fits | Cost |
|---|---|---|
In-list search box that filters or jumps with scrollToKey | Admin list, chat (server search) | A real feature to build; users must learn to use it instead of Ctrl+F. |
| Intercept Ctrl+F or Cmd+F while the list has focus and open our own search; a second press opens the browser's | Log viewer, code views | Overriding a browser shortcut surprises people; only do it inside a focused, app-like surface. |
Don't virtualize: render everything with content-visibility: auto | Lists under a few thousand rows where find matters | Back to full memory cost; fine at that size. |
Check your understanding · Exercise 11
A screen reader user arrows down through the virtualized user list. Which problems would they hit without the fixes in this chapter? Select all that apply.
Server rendering, Back navigation, memory, and proving it works
Rendering the first window on the server
The admin page is server-rendered so the first screen of users shows before JavaScript loads. The server has no viewport and no scrollTop, so the core can't compute a range. Give it an assumed viewport instead: TanStack Virtual calls this initialRect. With an assumed height of 900 px and estimates of 48 px, the server renders rows 0 to 18 plus overscan, at estimated offsets, inside a spacer sized from estimates.
- The client's first render must produce the same rows, so it uses the same assumed rect. Only after hydration does the adapter read the real viewport and update. Reading
window.innerHeightduring the first client render causes a hydration mismatch. - Server-rendered rows should be in normal flow (the padding mode from the DOM chapter) so the browser places them correctly before any measurement. If estimates are wrong, only the spacer below the viewport changes size, which doesn't shift anything on screen and doesn't count as layout shift.
- Server rendering a window doesn't make the list crawlable. If search engines must see all items, they need real paginated URLs, as the infinite scroll guide describes.
- For chat in reverse mode, skip server rendering of rows: the server can't know which end the reader starts at relative to their viewport, and a skeleton is cheaper than a wrong first window.
Coming back to the same place
The browser's own scroll restoration can't help: when the reader comes back, the spacer is built from estimates, so the saved scrollTop points at a different row. Save the anchor instead: the first visible row's key and its gap, plus the measured size map, in history.state or a module-level cache keyed by the page URL.
On return, pass initialIndex from the key, restore the gap, and reuse the sizes so offsets near the anchor are already exact. Set history.scrollRestoration = "manual" on these pages so the browser doesn't apply its own stale value first.
Memory that grows anyway
- The size map grows with the data: 200,000 numbers keyed by string ids is a few MB. Fine, but drop it when the list unmounts.
- Every row's ref callback must
unobserveon unmount. One shared observer that never forgets a row keeps detached nodes alive; the memory-leaks lesson shows how to spot them in a heap snapshot. - Rows with images that unmount let the browser drop their decoded bitmaps, which is a hidden win of windowing on image-heavy chat.
- The data itself is the page's problem: a log viewer holding 2 million parsed lines needs compact storage (arrays of strings, not objects per line), and maybe a worker that parses and indexes.
Testing the parts
| Layer | How to test | What it catches |
|---|---|---|
| Core math | Unit tests with plain arrays; property tests with random sizes asserting the range always covers the viewport | Off-by-one ranges, wrong binary search bounds, broken anchors |
| Anchoring and prepend | Real browser tests (Playwright): scroll up through estimated rows, assert the anchor row's screen position stays within 1 px | Jumps; jsdom has no layout, so it can't test this |
| Accessibility | Assert ARIA attributes; scripted keyboard runs that scroll 500 rows and check document.activeElement | Lost focus, wrong positions |
| Performance | Trace a scripted fling on a throttled CPU (4x to 6x slowdown); count frames over 16 ms | Regressions in row render cost |
Measuring it in production
Report a few numbers from real devices: time to first window, the share of scroll frames that render, how many anchor corrections happen per 1,000 px scrolled (a high number means bad estimates), and blank frames, detected by checking in a frame callback whether the viewport's top or bottom falls outside the rendered range. Interaction to Next Paint covers keyboard navigation; in Chromium, the Long Animation Frames API attributes slow scroll frames to the script that caused them.
Check your understanding · Exercise 12
The server-rendered admin list shows a hydration warning and a visible flicker on load. Which changes fix it? Select all that apply.
Running it in the interview
A virtualized list question is a depth test. Everyone knows "only render what's visible". The interviewer is listening for whether you can do the math out loud, whether you know where variable heights break things, and whether you bring up accessibility before being asked. Here is a 60 minute plan.
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 6 | Requirements: row count, fixed or unknown heights, direction (chat), box or window, server rendering, devices. | "Unknown heights and reverse mode are the two answers that change the design most." |
| 6 to 10 | Why virtualize; mention content-visibility as the cheap alternative. | "Under a few thousand rows I'd try CSS first and keep find-in-page." |
| 10 to 20 | Draw the scroll box, spacer, and placed rows. Do the fixed math, then the prefix sum and binary search. | Say the numbers: "100,000 rows of 48 px, scrollTop 96,000: first row 2,000." |
| 20 to 32 | Variable heights: estimate, ResizeObserver, anchor correction. Then prepend in chat. | "Measurements and the anchor are stored by key, never by index." |
| 32 to 40 | Architecture: headless core, adapter, measurer, hook, component. The API. | "The list reports its range; the page fetches." |
| 40 to 50 | Accessibility and find-in-page, then performance during flings. | "The focused row is always mounted." |
| 50 to 60 | Follow-ups: scroll to index, browser height limits, grids, server rendering. | Name the trade-off in every answer. |
Expect the interviewer to pick one thread and pull hard. The most common are the scrollbar, momentum on phones, text selection, and tables. Each answer below names the cause, the fix, and what the fix costs.
Follow-ups to expect
Think first
"Why does the scrollbar thumb change size while I scroll a list with variable heights?"
The total height is the sum of measured sizes and estimates. As rows are measured, estimates are replaced and the total changes, so the thumb's size and position shift a little. Anchoring keeps the content still, but it can't keep the thumb still.
Better estimates shrink the effect: use per-type estimates and the running average of measured rows. For lists where the thumb matters, like a log viewer, use fixed heights so the total is exact from the start.
Think first
"On iOS, a fling sometimes stops dead while scrolling up through chat history. Why?"
Writing scrollTop during momentum scrolling can cancel the momentum on some touch browsers. Scrolling up through unmeasured rows triggers anchor corrections, and each correction writes scrollTop.
Defer corrections for rows above the viewport while the user is scrolling, and apply them in one batch when scrolling settles; or improve estimates so corrections are rare. Products also prefetch and pre-measure older messages off screen so they arrive with known sizes.
Think first
"A user selects text across 200 messages and copies it. What happens?"
The selection only covers rendered rows. As the user drags, rows at the start of the selection unmount and the selection loses its anchor, so the copy contains only part of the text, or nothing.
Options: keep the rows between the selection's start and end mounted while a selection is active (bounded, say 500 rows), or offer an explicit "Copy messages" action that builds the text from data. Logs usually do the second, with a "copy lines 1,200 to 1,400" action.
Think first
"Can I use this for a table with sticky headers and 200 columns?"
Yes, through the hook: one virtualizer for rows on scrollTop and one for columns on scrollLeft, rendering the cross product. Sticky column headers sit outside the virtualized body and sync their horizontal offset. The data table guide walks through it.
Check your understanding · Exercise 13
Which answers would a strong senior candidate give? Select all that apply.
Summary
- Virtualize when the row count is large or unbounded; under a few thousand rows, try
content-visibility: autofirst, which keeps find-in-page and the accessibility tree. - The structure is a scroll box, one spacer with the total height, and about 30 rows placed at their offsets. Fixed heights need one division; variable heights need a prefix-sum offset array and a binary search.
- Unknown heights follow estimate, measure with
ResizeObserver, correct. Every correction restores an anchor row so the content under the reader doesn't move. - Store sizes, anchors, and focus by item key, never by index: prepending in chat shifts every index.
- Keep scroll frames cheap: compare ranges before rendering, memoize rows keyed by id, overscan toward the scroll direction, and use placeholders during fast flings. No
will-changeon rows. scrollToIndexwith unknown heights is a loop: jump, measure, correct, stop when stable or when the user scrolls.- Elements can't be taller than roughly 16.7 to 33.5 million px depending on the browser; past that, scale the scroll space or draw your own scrollbar.
- Pay back what virtualization removes:
aria-setsizeandaria-posinset(oraria-rowcountandaria-rowindex), a focused row that stays mounted, and a search for rows Ctrl+F can't see. - Build a headless core with no DOM, a scroll adapter, and a component on top of a hook. The list reports its range; the page fetches.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- TanStack Virtual: Virtualizer API A headless virtualizer: estimateSize, measureElement, overscan, rangeExtractor, initialRect.
- Virtualize large lists with react-window (web.dev) The basic idea with fixed and variable sizes, and overscan.
- react-virtuoso A component-first virtual list with reverse mode, firstItemIndex for prepending, and scroll seek placeholders.
- content-visibility: the new CSS property that boosts rendering performance (web.dev) The CSS alternative and contain-intrinsic-size.
- MDN: content-visibility
- MDN: ResizeObserver borderBoxSize, timing, and the loop limit error.
- MDN: overflow-anchor The browser's scroll anchoring, and how to turn it off.
- WebKit Features for Safari 27.0 Scroll anchoring arrives in Safari, the last major engine to add it.
- WAI-ARIA 1.2: aria-setsize, aria-posinset, aria-rowcount
- WAI-ARIA Authoring Practices: Feed pattern Articles with position attributes and aria-busy while loading.
Concepts behind it
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Layout, paint, and compositingWhat the browser redoes when something on the page changes after the first render, why moving a box with
transformis cheap while changing itswidthis not, and how to keep scrolling and animation inside a 16 ms frame. - Memory in long-lived appsWhy single-page apps that stay open for hours fill up memory, how the garbage collector decides what to keep, the classic leaks, and how to find them with heap snapshots and keep them out with budgets.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.