List virtualization
Rendering 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.
The problem: a list that is too big to draw
Picture a support tool that shows every ticket for a large customer: 10,000 rows. Each row has an avatar, a subject, a status badge, a date, and two buttons. That is about 15 DOM elements per row, so the list alone is 150,000 elements.
The data is only about 2 MB of JSON. The page, though, freezes for a second or two when it opens, scrolling stutters, and typing in the search box above the list lags.
The data was never the problem. The browser has to build, style, lay out, and paint all 150,000 elements, while the screen shows about 20 rows. The user sees 0.2% of the list and pays for 100% of it.
Where the cost goes
Every element in the DOM costs something in four places. The lesson on reflow, repaint, and composite covers the pipeline in depth; here is the short version as it applies to a long list.
- Creating it. Your framework runs component code for each row, then the browser creates the nodes. In React, 10,000 rows means 10,000 component renders before anything shows.
- Style. The browser matches CSS rules against every element and computes its styles.
- Layout. The browser works out the size and position of every box. A row near the top that grows by 20 px can force it to place every box below again.
- Memory. The lesson on estimation budgets 0.5 to 1 KB per element all in, so 150,000 elements is 75 to 150 MB: enough for a phone to kill the tab in the background.
For years Lighthouse warned about DOM size above about 800 elements and failed the audit above about 1,400. Newer Lighthouse versions measure slow style and layout updates instead, but 1,400 is still a handy guideline. Our ticket list is about 100 times past it.
The idea in one sentence
List virtualization (also called windowing) means you keep all 10,000 items as data, but only create DOM for the rows inside the visible area plus a few extra above and below. As the user scrolls, you swap which rows exist. A spacer keeps the scroll bar the right size, so the list looks and feels like it has 10,000 rows.
With 20 visible rows and 5 extra on each side, the DOM holds 30 rows, about 450 elements, no matter whether the list has 100 items or 1,000,000. The cost of the list now depends on the size of the screen, not the size of the data.
Check your understanding · Exercise 1
A list of 5,000 products renders in 1.8 seconds on a mid-range phone. The JSON for all 5,000 is 600 KB and parses in 15 ms. What is the main cost to cut? Pick one.
The mental model: a window over a tall strip
Think of a film strip 10,000 frames long and a small window you look through. You never need the whole strip in front of you, only the few frames behind the window. As you slide the window, frames leave on one side and new ones arrive on the other. Virtualization does the same with rows.
Three layers: viewport, spacer, rendered rows
Every virtual list, in every library, is built from the same three layers. Once you can draw them, you can explain any library's API.
- The viewport (also called the scroll container). A box with a fixed height, say 600 px, and
overflow-y: auto. It is the window. The user scrolls it, and it tells you how far they scrolled withscrollTop. - The spacer. One empty element inside the viewport whose height is the height of the whole list: 10,000 rows x 40 px = 400,000 px. It holds no content. It exists so the browser draws a scroll bar of the right size and lets the user scroll the full distance.
- The rendered rows. The 15 to 30 rows that are visible or nearly visible. Each is placed at its true position in the full list, either with
position: absolute; top: ...or withtransform: translateY(...). Row 5,000 sits at 200,000 px, exactly where it would be if all rows existed.
index x 40 px.The user cannot tell the difference. The scroll bar is the right size, the rows are where they should be, and scrolling is smooth. The only thing missing is the 9,970 rows nobody is looking at.
The loop that runs on every scroll
A virtual list is a small loop. The browser fires a scroll event as the user scrolls, and the list reads scrollTop, works out which rows overlap the visible area, and renders that slice. Rows that left the window are unmounted; rows that entered are mounted. Nothing else changes.
scroll event
-> read scrollTop (say 12,000 px)
-> first visible row = floor(12,000 / 40) = 300
-> last visible row = ceil((12,000 + 600) / 40) - 1 = 314
-> add overscan: render rows 295 to 319
-> place each row at index x 40 px
-> browser paints the new frame
Scroll events can arrive every frame, 60 to 120 times a second, so the loop must be cheap. Finding the range is a division (or a binary search for variable rows) and takes microseconds. The real cost is mounting new rows, so what matters is how many rows change per frame, not how long the list is.
What it is not
Virtualization is often mixed up with three nearby ideas. Keep them apart, because an interviewer will often ask which one you mean.
| Technique | What it limits | What stays in memory |
|---|---|---|
| Virtualization | DOM: only visible rows are rendered | All the data you have loaded |
| Pagination | Data: only one page is fetched and shown | One page of data and DOM |
| Infinite scroll | Network: the next page is fetched near the bottom | Every page loaded so far, unless you also virtualize |
| Lazy loading | Work for heavy parts such as images, until they come near the screen | All rows, with light placeholders |
Think first
Why does a virtual list need a spacer at all? Could it just render 30 rows and move them as the user scrolls?
Without a spacer the scroll container would be only 30 rows tall, so there would be nothing to scroll: scrollTop would stay near 0 and the scroll bar would show a short list.
The spacer gives the browser the full height, so native scrolling, the scroll bar thumb, keyboard scrolling, and momentum on phones all work as normal. The list only reacts to where the user has scrolled; it never fakes the scrolling itself.
Fixed-height rows, step by step
The simplest case is a list where every row has the same height, like a log viewer or a table of transactions with one line per row. The math is simple enough to do in your head, and it is the base every other case builds on.
A worked example
Take 10,000 rows, each exactly 40 px tall, in a viewport 600 px tall. The user has scrolled 12,000 px down. Here is everything the list needs to know, in order.
- Total height: 10,000 x 40 = 400,000 px. This is the spacer's height, and it only changes when the item count changes.
- First visible row:
floor(12,000 / 40)= row 300. Its top edge is exactly at the top of the viewport. - Last visible row: the bottom of the viewport is at 12,600 px, so
ceil(12,600 / 40) - 1= row 314. That is 15 rows on screen. - Overscan: with 5 extra rows on each side, render rows 295 to 319: 25 rows in total.
- Position: row 295 goes at 295 x 40 = 11,800 px from the top of the spacer, row 296 at 11,840 px, and so on.
If the user scrolls 20 px further, to 12,020 px, row 300 is still the first visible row, but row 315 now peeks in at the bottom, so the list adds it. At 12,040 px row 295 leaves. Every 40 px of scrolling swaps one row in and one row out, which is why fixed rows are so cheap to scroll.
The whole algorithm in 12 lines
Here is a complete range function for fixed rows. It is framework-free: give it the scroll position and sizes, get back which rows to render and where the first one goes. It clamps at both ends, so a negative scrollTop (the bounce on iOS) or a list shorter than the viewport does not break it.
function getRange(scrollTop: number, viewportHeight: number,
rowHeight: number, count: number, overscan = 3) {
const total = count * rowHeight;
if (count === 0) return { start: 0, end: -1, offset: 0, total };
const top = Math.max(0, Math.min(scrollTop, total - viewportHeight));
const first = Math.floor(top / rowHeight);
const last = Math.min(count - 1,
Math.ceil((top + viewportHeight) / rowHeight) - 1);
const start = Math.max(0, first - overscan);
const end = Math.min(count - 1, last + overscan);
return { start, end, offset: start * rowHeight, total };
}
getRange(12000, 600, 40, 10000, 5);
// { start: 295, end: 319, offset: 11800, total: 400000 }
start to end are rendered (both included); offset is where the first one goes.Test the edges: scroll at 0, scroll past the end, a list shorter than the viewport, an empty list, and a scrollTop halfway through a row. These catch nearly every off-by-one bug.
Wiring it up to the DOM
In React, the component keeps scrollTop in state, updates it on scroll, and renders the slice. The rows sit inside the spacer, inside one wrapper moved down by offset.
function VirtualList({ items, rowHeight = 40, height = 600 }) {
const [scrollTop, setScrollTop] = useState(0);
const { start, end, offset, total } =
getRange(scrollTop, height, rowHeight, items.length, 5);
return (
<div style={{ height, overflowY: "auto" }}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
<div style={{ height: total, position: "relative" }}>
<div style={{ transform: `translateY(${offset}px)` }}>
{items.slice(start, end + 1).map((item) => (
<Row key={item.id} item={item} height={rowHeight} />
))}
</div>
</div>
</div>
);
}
Three details in this sketch matter more than they look.
transformfor the moving wrapper. Changingtransformusually skips layout; changingtopdoes not.- Stable keys. With the index as key, a row's component is reused for a different item when the slice moves, so input text, focus, and open menus jump to the wrong item.
- Cheap rows. A row that mounts in 15 ms makes every scroll that brings in a new row miss a frame. Memoize rows (
React.memo) so rows that stay in range do not re-render.
Check your understanding · Exercise 2
Rows are 50 px tall, the viewport is 500 px, there are 1,000 rows, and scrollTop is 2,475. With an overscan of 2, which rows are rendered? Pick one.
Overscan, fast scrolling, and the frame budget
The worked example rendered 5 extra rows on each side. Those extra rows are called overscan (TanStack Virtual's name; react-window calls it overscanCount, and react-virtuoso uses increaseViewportBy, measured in pixels). This chapter explains why they exist and how to pick the number.
Why fast scrolling shows blank gaps
In modern browsers, scrolling does not wait for your JavaScript. The browser architecture lesson explains that the compositor thread moves the page on screen by itself, using layers it already has. Your scroll handler runs later on the main thread, and the new rows appear one frame or more after that.
So during a quick flick the compositor can move past the rows you have rendered before your code catches up, and the user sees empty space for a frame or two. A fast fling on a phone moves 3,000 to 5,000 px per second: 50 to 80 px every frame at 60 Hz.
Overscan is the buffer for that gap. If 5 rows (200 px) are ready below the visible area, the compositor has 200 px of real content to show before it runs out, which covers about 3 frames of a fast fling.
Choosing the number
Overscan is a trade between blank gaps and work per frame. More overscan means fewer gaps, but more rows to mount when the range moves and more DOM at rest. A rough guide:
| Overscan | Rows in DOM (15 visible) | Good for |
|---|---|---|
| 0 | 15 | Rarely: fast scrolls show a flash at the edge |
| 2 to 5 rows | 19 to 25 | Most lists with cheap rows: tables, logs, menus |
| 5 to 10 rows | 25 to 35 | Fast-flung feeds on phones, rows with images |
| 20+ rows | 55+ | Rarely worth it: you start to lose the point of virtualizing |
Some lists use direction-aware overscan: more rows in the scroll direction and fewer behind, say 8 below and 2 above while scrolling down.
The frame budget
At 60 frames a second the browser has 16.7 ms per frame for everything: your scroll handler, React's render, style, layout, and paint. At 120 Hz it has 8.3 ms. If mounting the new rows takes 25 ms, at least one frame is dropped and the user feels a stutter.
Cost per frame is roughly rows that change times the cost of one row. Scrolling 80 px a frame through 40 px rows mounts about 2 rows a frame: at 1 ms each, well inside the budget. A list that re-renders all 25 rows on every scroll (rows not memoized, or keys that change) pays 25 ms and stutters.
- Keep the scroll handler tiny. Read
scrollTop, set state, return. Avoidwheelortouchmovelisteners that are not passive on the container, because they make the browser wait for your code before it scrolls. - Do not debounce the scroll handler. Debouncing waits for scrolling to stop, so the list shows blank space for the whole scroll.
- Show a cheap placeholder while flinging if rows are heavy: some libraries, such as TanStack Virtual, expose
isScrolling, so you can draw a grey box instead of a chart until scrolling stops. - Move heavy work out of rows. Format dates and parse markdown once when the data arrives, not in each render.
Try it below. The lab has 10,000 rows. Turn virtualization off and on and change the overscan, then compare how many DOM nodes the list holds and the estimated cost of a frame while scrolling. The costs come from a rough model (0.05 to 0.5 ms to mount a row, plus about 1 microsecond of style and layout per element), not from measurements.
| Rows in the DOM | 21 (rows 2,997 to 3,017) of 10,000 |
|---|---|
| DOM elements | 168 (under the 1,400 guideline) |
| DOM memory, about 0.75 KB each | 0.12 MB |
| Rows mounted per scroll frame | 2 in, 2 out |
| Blank flashes while scrolling | likely: 160 px can pass in two frames, overscan covers 120 px |
Check your understanding · Exercise 3
Users report white flashes at the bottom of a virtual feed when they fling on Android. Rows are memoized and mount in about 1 ms each. Overscan is 1. What do you change first? Pick one.
Variable heights: estimate, measure, cache
Real lists are rarely that tidy. A chat message can be one line or twenty; a feed card may or may not have an image. You cannot find row 300 by computing 300 x 40, because the rows above it are all different sizes.
Worse, you usually do not know a row's height until it is in the DOM. Its height depends on the text, the font, the width of the screen, and whether its image has loaded. So a variable-height list has to work with guesses first and real sizes later.
The position cache
The core data structure is a position cache: for each row, its height and its top offset (the sum of the heights of all rows above it). This running total is also called a prefix sum. With it, the list can answer both questions it needs on every scroll.
- Where does row i start? Read
offsets[i]. One array lookup. - Which row is at pixel y? The offsets only ever grow, so binary search them for the last row whose offset is at or below
y. For 10,000 rows that is about 14 comparisons.
scrollTop. When an estimate is replaced by a measurement, every offset after it shifts.// offsets[i] = top of row i; offsets has count + 1 entries,
// and offsets[count] is the total height.
function rowAt(offsets: number[], y: number): number {
let lo = 0, hi = offsets.length - 2;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (offsets[mid] <= y) lo = mid; else hi = mid - 1;
}
return lo;
}
// heights 40, 80, 40, 120 -> offsets 0, 40, 120, 160, 280
rowAt([0, 40, 120, 160, 280], 130); // 2
rowAt(offsets, scrollTop); walk forward until a row's top passes the viewport's bottom.Estimate first, measure when rendered
Every row starts with an estimated size: a guess you supply, such as 80 px for a chat message. The list builds the position cache from the estimates, so the spacer has a height and the scroll bar works from the first frame.
When a row mounts, the list measures it. The modern way is a ResizeObserver on each rendered row: it reports the row's real size after layout, and it fires again whenever the size changes later, for example when an image loads or the user expands a reply. The list writes the real height into the cache and fixes the offsets of every row below it.
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const index = Number(entry.target.getAttribute("data-index"));
const height = entry.borderBoxSize[0].blockSize;
if (height !== cache.size(index)) cache.setSize(index, height);
}
scheduleRender(); // once per batch, not once per row
});
// when a row mounts: ro.observe(rowElement)
// when it unmounts: ro.unobserve(rowElement)
measureElement works this way: rows carry data-index and one observer measures them all.Updating offsets is the expensive part: if row 300 changes, rows 301 to 9,999 all move. Recomputing 10,000 sums still takes well under a millisecond, so libraries simply recompute from the changed row down, lazily. Lists with hundreds of thousands of rows sometimes use a Fenwick tree, which updates and sums in log n steps.
Why the estimate matters
A bad estimate does not break the list, but users feel it in two ways.
- The scroll bar lies. If you estimate 50 px and real rows average 150 px, the spacer is 500,000 px instead of 1,500,000 px. The thumb shrinks and jumps as rows get measured, and dragging it to the middle does not land in the middle of the list.
- Content jumps. When rows above the viewport are measured and turn out taller than guessed, everything below them, including what the user is reading, is pushed down. Chapter 6 shows how to correct for this.
So estimate close to the average, not the minimum, and per item type where you can: 48 px for a one-line system message, 140 px for a message with an image. If the server sends image dimensions, reserve the right space before the image loads, which also helps Cumulative Layout Shift (CLS).
Decision How should the list know each row's height?
What we pick: Fixed heights for tables, logs, and pickers with one-line rows: fastest and simplest. Estimate-then-measure for feeds, chat, and comments. Mix in known sizes where the data gives them (image dimensions), because good estimates make every other problem smaller.
Check your understanding · Exercise 4
A chat list estimates every message at 40 px, but most messages are 90 to 150 px. What does the user notice most? Pick one.
Scroll anchoring: keeping the reader still
A user is reading message 300 in a chat. Above the viewport, row 290 gets measured and is 120 px taller than its estimate. Every row below moves down 120 px, but scrollTop has not changed, so the text being read jumps and the user loses their place.
This is the most visible bug in variable-height lists, and it has the same cause in every case: something above the viewport changed height, but scrollTop stayed the same. The fix is always the same as well: change scrollTop by the same amount, in the same frame, so the content on screen does not move.
Four things that move content
- Measurement above the viewport. A row that was estimated is now measured, often when the user scrolls up into rows they have never seen.
- Rows resizing. An image loads, a code block is highlighted, a reaction bar appears, a user expands a thread.
- Prepending items. The chat loads 50 older messages above the oldest one. Without a fix, the list jumps by 50 messages' worth of height, or worse, shows the very top of the new batch.
- New items at the top of a feed. Ten new posts arrive above the post the user is reading.
The correction
Pick an anchor: usually the first row that is visible, plus how far its top is from the viewport's top. Before the change, note the anchor's offset. After the change and before the browser paints, read the anchor's new offset and add the difference to scrollTop.
// before the change
const anchor = rowAt(offsets, el.scrollTop);
const before = offsets[anchor];
applyChange(); // measure, resize, or prepend; offsets are rebuilt
// after the change, in the same frame (useLayoutEffect in React)
const after = offsets[anchorIndexAfterChange(anchor)];
el.scrollTop += after - before;
Libraries do this for you in the common cases. TanStack Virtual adjusts scrollTop when a row above the viewport changes size. react-virtuoso is built around it: for chat, you tell it the index of the first item (firstItemIndex) and lower it when you prepend, and it keeps the view still. If you write the list yourself, this is the code to get right first.
The browser's own scroll anchoring
Browsers have a built-in version, also called scroll anchoring, controlled by the CSS property overflow-anchor. When content above the visible area changes size, the browser adjusts the scroll position itself. It is on by default in Chrome, Edge, and Firefox, and Safari added it only in Safari 27 (September 2026). Apps that must work on older iPhones and Macs still correct the scroll themselves.
In a virtual list the browser's anchoring can fight the library's correction: both adjust, and the content jumps the other way. So many setups turn it off on the scroll container with overflow-anchor: none and let the library do the work.
Try both below. Load older messages or let an image load in a message above the viewport, with the correction on and off, and watch where the message you were reading ends up.
scrollTop. When content above it grows, the same scrollTop points at different messages unless you add the growth to it.You are reading message 42.
Chat: anchoring to the bottom
Chat lists are anchored at the bottom: they open at the newest message, and when a new message arrives the list should follow it, but only if the user is already at the bottom. If they scrolled up to read history, a new message must not drag them down; show a "New messages" button instead.
- Track whether the user is at the bottom:
scrollHeight - scrollTop - clientHeightunder about 4 px. - On a new message: if at the bottom, scroll to the end after it renders; if not, keep
scrollTopand show a counter. - On loading older messages: apply the anchor correction above, so the top of the view stays still.
- Some apps use
flex-direction: column-reverseso the bottom is the start. Most virtual list libraries assume normal direction, so check before combining them.
Check your understanding · Exercise 5
A chat prepends 50 older messages (total height 4,000 px) while the user is reading. scrollTop was 200. What should scrollTop be after the prepend to keep the view still? Pick one.
Sticky headers, grids, and other layouts
Real products are rarely a single column of rows. They have group headers that stick while you scroll, tables with frozen columns, grids of photos, and pages where the whole window scrolls rather than a box. Each changes the basic recipe a little.
Sticky group headers
A contacts list groups names under A, B, C, and the current letter sticks to the top. In a normal list, position: sticky does this for free. In a virtual list it breaks: the header for "M" is a row like any other, so when the user scrolls far enough past it, it leaves the range and is unmounted, and the sticky header vanishes.
- Keep the active header mounted. Treat headers as rows in the data, find the last header at or above the first visible row, and always include it in the rendered set even when it is out of range. TanStack Virtual lets you do this with a custom
rangeExtractor. - Or draw a separate overlay. Render one header element outside the list, pinned to the top, and update its text as the first visible group changes. react-virtuoso's
GroupedVirtuosohandles this for you.
Grids and two-dimensional windowing
A spreadsheet with 100,000 rows and 200 columns has 20 million cells. Windowing only the rows still leaves 200 cells per row, so 30 rows is 6,000 cells. A grid virtualizer windows both directions: it computes a row range from scrollTop and a column range from scrollLeft, and renders only the cells where they cross.
scrollTop, the column range from scrollLeft, and only the cells where they cross are rendered. The DOM size depends on the screen in both directions.With 30 visible rows and 12 visible columns plus overscan, the grid holds about 500 cells instead of 20 million. Frozen header rows and columns are separate, smaller virtual lists that share the scroll position. Photo grids with a fixed column count are simpler: group items into rows of 4 and virtualize the rows.
Past a certain size even windowed DOM is too slow. Google Sheets draws its grid on a canvas and keeps a small DOM layer for editing and accessibility: a big investment, worth it only at that scale.
Real tables
Absolute positioning does not mix with table layout: a tr with position: absolute loses its column widths. Either keep the table and add two spacer rows (one for the skipped rows above, one for those below), or build the table with CSS grid and ARIA table roles so rows can be placed freely.
Window scrolling and very long lists
- The whole page scrolls. Feeds usually scroll the window, so the list reads
window.scrollYminus its own distance from the top of the page. TanStack Virtual hasuseWindowVirtualizer; react-virtuoso hasuseWindowScroll. - The height limit. Browsers cap element height in the tens of millions of pixels. Measured limits vary by engine and version: recent tests put scroll content at about 16.7 million px in Chromium, about 17.9 million in Firefox, and about 33.5 million in Safari, so 1 million rows of 40 px (40 million px) does not fit. Huge lists scale the scroll position, but at that size search and filters beat scrolling.
Think first
A grid has 50,000 rows and 300 columns. Row virtualization alone keeps 40 rows mounted. Is that enough?
No. 40 rows x 300 columns is 12,000 cells, which is already far past the 1,400 element guideline before you count anything inside the cells.
Window the columns too. With about 15 visible columns plus overscan, the grid holds around 40 x 20 = 800 cells, and the cost no longer depends on how wide the sheet is.
What you give up: accessibility, find-in-page, and a lighter alternative
Virtualization has a cost that does not show up in a profile. Rows that are not in the DOM do not exist for screen readers, Ctrl+F, select-all and copy, extensions, or crawlers. A good virtual list wins back what it can and is honest about the rest.
Screen readers see only rendered rows
A screen reader builds its view from the accessibility tree, which comes from the DOM. With 25 rows rendered, it announces "list, 25 items" for a list of 10,000, and the reading cursor hits the end after row 25.
ARIA lets you tell the true size and position, even though most rows are missing. Which attributes you use depends on the role.
| Pattern | On the container | On each rendered row |
|---|---|---|
| List or listbox | role="list" or role="listbox" | aria-setsize="10000", aria-posinset="301" (1-based) |
| Table or grid | role="grid" with aria-rowcount="10001" (header row counts) | aria-rowindex="302" (1-based, header is row 1) |
| Feed of articles | role="feed", aria-busy="true" while loading | role="article" with aria-setsize (or -1 if unknown) and aria-posinset |
<div role="grid" aria-rowcount="10001" aria-label="Tickets">
<div role="row" aria-rowindex="1">...column headers...</div>
<!-- rows 2 to 296 are not in the DOM -->
<div role="row" aria-rowindex="297">...</div>
<div role="row" aria-rowindex="298">...</div>
<!-- ... up to row 321, then nothing until 10,001 -->
</div>
aria-rowindex tell assistive technology that rows are missing, not that the table is short.aria-rowcount on the grid and aria-rowindex on each row let a screen reader say where the user is in the full list.Focus and the keyboard
- Never unmount the focused row. If a keyboard user is on row 40 and scrolls away with the mouse wheel, unmounting it drops focus to the
body. Keep the focused row mounted even out of range. - Arrow keys scroll, then focus. If the next row is not rendered, call the library's
scrollToIndex, then focus it once it mounts. - Home and End mean the list. End scrolls to the last item, renders it, and focuses it; grids also expect Ctrl+Home and Ctrl+End.
- Careful with
aria-activedescendant. It points at an element by id, so that element must stay in the DOM.
Find in page, copy, and crawlers
Ctrl+F searches the text in the DOM, so in a virtual list it finds only the rendered rows. A user searching a 10,000 line log for "timeout" gets no results even though the word is on line 7,120. Select-all, copy, and crawlers have the same limit.
- Offer your own search. For logs, docs, and tables, a search box that jumps to matches in the data beats Ctrl+F anyway. Code editors and log viewers sometimes catch Ctrl+F and focus their own box.
- Offer an export ("Download CSV", "Copy all") when users need all the content at once.
- For SEO, render the first page on the server and link to paginated URLs (
?page=2) so crawlers reach every item.
The lighter alternative: content-visibility
CSS has a built-in middle ground. content-visibility: auto on each row tells the browser it may skip style, layout, and paint for that row while it is far off screen. The row stays in the DOM, so Ctrl+F, screen readers, anchor links, and select-all still work. When the user scrolls near it, the browser renders it.
.row {
content-visibility: auto;
/* size to assume while skipped; "auto" remembers the real size
once the row has been rendered once */
contain-intrinsic-size: auto 80px;
}
It is Baseline since September 2024, when Safari 18 added it, so you can use it in all current browsers. One caveat: Safari 18 to 25 could not find text in skipped rows with Ctrl+F; Safari 26 fixed it. contain-intrinsic-size plays the role of the estimated size: it keeps the scroll bar steady while rows are skipped.
The catch: the DOM nodes still exist. You still create all 150,000 elements, keep them in memory, and render all 10,000 components. It cuts rendering cost, not DOM cost: a great fit for articles, docs, and lists of up to a couple of thousand rows; past that, virtualize.
Check your understanding · Exercise 6
A docs site renders a 2,000-item API reference on one page. Users rely on Ctrl+F. Rendering is slow on phones. What fits best? Pick one.
Libraries, real products, and choosing an approach
A fixed-height list takes an afternoon. Variable heights, anchoring, sticky headers, window scrolling, and focus are each a week of edge cases, so teams pick a library and spend their time on the rows.
The three libraries you will hear about
- TanStack Virtual. Headless: it computes which items to render and where, and you write the markup. Works with React, Vue, Solid, Svelte, Lit, and Angular.
- react-window. A small React library with ready
ListandGridcomponents, strongest when sizes are fixed or known. - react-virtuoso. A React library that measures every item for you and builds in the hard cases: sticky groups, tables, and chat.
All three use the model from this lesson: a range from the scroll position, a size per item, and placed items inside a full-height spacer. They differ in how much they decide for you: the more they decide, the less code you write and the less control you keep over markup and accessibility.
Decision Which approach should a new long list use?
What we pick: For a data table or picker with one-line rows, react-window or TanStack Virtual. For a chat or a feed with rich, changing rows in React, react-virtuoso saves weeks. For a design system that must control its markup or support several frameworks, TanStack Virtual. For an article or a list of a few hundred items, start with content-visibility and measure.
Where you see it in real products
- Code editors. VS Code's editor (Monaco) renders only the lines in view, which is why a 100,000 line file scrolls smoothly.
- Spreadsheets. Google Sheets draws cells on a canvas and keeps only a thin DOM layer, the far end of the same idea.
- Chat apps. Clients load years of history in pages and keep only part of it rendered, with bottom anchoring and scroll correction.
- Feeds. Large social feeds combine infinite scroll with windowing, so an hour-long session does not grow the DOM without limit.
- DevTools. Chrome DevTools windows its long views, such as the console and the Network panel's request list, so a page with thousands of requests stays usable.
When not to virtualize
- Short lists. Under a few hundred simple rows, the extra code and the lost Ctrl+F are not worth it.
- Pages people read top to bottom, like articles and docs: use
content-visibilityinstead. - When the real fix is less data. A search result page with 10,000 results nobody will scroll through needs better ranking and filters, not a virtual list.
- Print. A virtual list prints only what is rendered. If printing matters, render a print view separately.
Check your understanding · Exercise 7
You build a customer support chat in React. Messages vary from one line to long code blocks with images; older history loads when the user scrolls up; new messages arrive live. Which choice saves the most work? Pick one.
Using it in a system design interview
Virtualization comes up in almost every design question with a list: feeds, chat, email, data tables, galleries. Interviewers want to see that you know when it is needed, what it costs, and how the hard parts work.
When to bring it up
Bring it up when you estimate the list size, right after the requirements, with the math out loud: "500 posts a session at 40 elements each is 20,000 elements, far past the 1,400 guideline, so the feed windows its rows and keeps about 20 mounted."
For short lists, say why you will not virtualize: "The autocomplete shows at most 10 results, so no windowing here." Knowing when not to use a tool is as strong a signal as knowing the tool.
What to draw
- The three layers: the scroll container, the full-height spacer, and the small set of rendered rows with an overscan band above and below.
- The data flow: the infinite-scroll loader fetches pages into a store; the virtualizer reads the store and renders a window of it. Two separate concerns, each with its own trigger.
- For variable rows: the position cache (estimated, then measured heights and their offsets).
- For chat: the anchor row and the scroll correction on prepend, plus "stick to bottom only if already at the bottom".
How deep to go by level
| Level | What a strong answer covers |
|---|---|
| Junior | Rendering only visible rows, why a big DOM is slow, naming a library, stable keys |
| Mid | The range math for fixed rows, overscan and why it exists, estimated sizes and measuring with ResizeObserver, how it fits with infinite scroll |
| Senior | Scroll anchoring and prepend correction, keeping focused rows mounted, aria-rowcount/aria-setsize, the Ctrl+F trade-off and a custom search, content-visibility as an alternative, frame budget per scroll |
| Staff | When not to virtualize, a shared list primitive for the design system, grids and canvas past the DOM's limits, measuring success with INP and long animation frames on real devices |
Check your understanding · Exercise 8
In an email client design, the interviewer asks: "The inbox has 50,000 emails. How do you render it?" Which answer is strongest? Pick one.
Summary
- Virtualization keeps all the data but only renders the rows in view plus overscan, so DOM size depends on the screen, not the list: about 30 rows for 10,000 or 1,000,000 items.
- Every virtual list has three layers: a scroll container, a full-height spacer for the scroll bar, and a few rows placed at their true offsets.
- Fixed heights are pure math (
floor(scrollTop / rowHeight)). Variable heights need estimated sizes, measurement withResizeObserver, and a position cache searched with binary search. - Overscan hides blank gaps during fast scrolling, because the compositor scrolls ahead of your JavaScript. Keep rows cheap and memoized so each frame mounts only a few.
- When anything above the viewport changes height, correct
scrollTopby the same amount before paint. Chat needs this for prepends, plus stick-to-bottom only when the user is at the bottom. - Unrendered rows are invisible to screen readers, Ctrl+F, copy, and crawlers. Add ARIA size and position, keep the focused row mounted, and provide your own search.
content-visibility: autois the lighter option: rows stay in the DOM so find and accessibility work, but you still pay to create every element.- Use a library (TanStack Virtual, react-window, react-virtuoso) and spend your effort on cheap rows, good estimates, and the accessibility work.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
What is list virtualization, and when would you use it?
It means keeping all the data but rendering DOM only for the rows in view plus a small overscan buffer. A spacer with the full list height keeps the scroll bar honest, and rendered rows are placed at their true offsets, so the user cannot tell.
I use it when a list can pass a few hundred rich rows or the 1,400 element guideline: feeds, chat, logs, big tables. The DOM then stays around 20 to 40 rows whatever the data size. For short lists, or pages users read and search top to bottom, I skip it or use content-visibility: auto instead.
Question 2
How do you virtualize rows whose heights you do not know in advance?
Start every row with an estimated size, close to the real average, and build a position cache of heights and running offsets. Find the first visible row with a binary search over the offsets.
When a row mounts, measure it with a ResizeObserver, store the real height, and update the offsets below it. The observer also catches later changes, such as images loading. When a row above the viewport changes height, adjust scrollTop by the difference before paint so the content the user is reading stays still.
Question 3
A chat loads older messages when the user scrolls to the top, and the view jumps. Why, and how do you fix it?
The new messages are inserted above the content on screen, which pushes it down by their total height, but scrollTop stays the same. So the viewport now shows the new history instead of what the user was reading.
Record the first visible message's id and its offset before the prepend, then after the new rows render, look up its new offset and add the difference to scrollTop in a layout effect. Libraries like react-virtuoso do this with firstItemIndex. Turn off the browser's own overflow-anchor on the container if it fights the library.
Question 4
What does virtualization cost for accessibility and find-in-page, and how do you reduce it?
Rows that are not in the DOM do not exist for screen readers or Ctrl+F. A screen reader would announce 25 items in a list of 10,000, and searching the page misses everything off screen.
Use aria-setsize and aria-posinset for lists and feeds, or aria-rowcount and aria-rowindex for grids, so assistive technology knows the real size and position. Keep the focused row mounted, support arrow keys, Home, and End with scroll-to-index, and give the product its own search and export. If full find-in-page matters and the list is a few thousand rows at most, use content-visibility: auto instead.
Question 5
How do you choose the overscan, and what happens if you get it wrong?
Overscan is the number of extra rows rendered past each edge. Scrolling runs on the compositor ahead of the main thread, so during a fast fling the screen can move past the rendered rows; overscan is the buffer that hides that.
Too little, and users see blank flashes at the edge. Too much, and each scroll mounts more rows and the DOM grows, which costs frame time. Start at 3 to 5 rows, raise it for flung feeds on phones if rows are cheap, consider more in the scroll direction, and check on a real mid-range device.
Practice it
These design problems lean on list virtualization.
- Design an autocompleteMedium
- Design a custom select dropdownMedium
- Design an infinite scroll listMedium
- Design a throttled progress bar queueMedium
- Design a file-explorer tree viewMedium
- Design a command paletteMedium
- Design a drag-and-drop reorderable listMedium
- Design an emoji and GIF pickerMedium
- Design a podcast player with a synced transcriptMedium
- Design a nested comments threadMedium
- Design a CSV import with column mappingMedium
- Design a real-time leaderboardMedium
- Design PinterestMedium
- Design an AI image generation studioMedium
- Design a data tableHard
- Design a virtualized listHard
- Design a Kanban boardHard
- Design a combobox for 100,000 optionsHard
- Design an embeddable code editorHard
- Design a PDF viewerHard
- Design a news feedHard
- Design a chat appHard
- Design a live cricket score page for 50 million fansHard
- Design InstagramHard
- Design DiscordHard
- Design NetflixHard
- Design the Spotify web playerHard
- Design a flight booking siteHard
- Design Google SheetsHard
- Design a collaborative design tool like FigmaHard
- Design NotionHard
- Design GmailHard
- Design Google CalendarHard
- Design Google DriveHard
- Design SlackHard
- Design a code review diff viewerHard
- Design an IoT dashboard for thousands of sensorsHard
- Design a log viewer for millions of linesHard
- Design Google PhotosHard
References
- web.dev: Virtualize large lists with react-window Why windowing helps, with a walk-through of react-window.
- web.dev: content-visibility How
content-visibility: autoskips rendering for off-screen content. - MDN: content-visibility The property, its values, and browser support.
- MDN: overflow-anchor The browser's scroll anchoring and how to turn it off.
- MDN: ResizeObserver Measuring elements when their size changes.
- WAI-ARIA: aria-rowcount Telling assistive technology the real size of a partly rendered grid.
- WAI-ARIA APG: Feed pattern Accessible endless lists of articles with
aria-setsizeandaria-posinset. - TanStack Virtual docs A headless virtualizer for React, Vue, Solid, Svelte, Lit, and Angular.
Read next
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- 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. - Back-of-the-envelope numbers for frontendQuick, rough math with round numbers (bytes over the network, CPU time on a cheap phone, memory in a long-lived tab, and requests per second at the server) that turns "this might be slow" into a reason to pick one design over another.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Inside the browserHow a modern browser splits its work across processes and threads, why your JavaScript shares one main thread with layout, paint, and input, and how the event loop, workers, and OffscreenCanvas decide whether a page stays responsive.