Making a Long List Fast: content-visibility vs. Virtualization

Want more of these in your Google results?
Two fixes for a slow list have become the standard answers, and they don’t solve the same problem.
content-visibility: auto is a CSS property that leaves every row in the page and lets the browser skip the rendering work for the rows nobody is looking at.
Virtualization does the opposite and never creates the offscreen rows at all, so the page holds only the dozen or so that fit on screen.
Both make a long list faster.
Only one keeps the behaviors the page gets from the DOM without extra work.
The costs of the other one show up in places nobody asked about. Find-in-page stops finding rows, since the text isn’t there to match. A deep link, a link that points at one row, lands on nothing. The row that holds your keyboard focus is destroyed when it scrolls away, and your place on the page is lost with it. Nobody decides to give those up; they come bundled with the tool and never show up in a diff, which is how a tidy pull request removes behavior the product depended on.
A slow list can be slow for several reasons that look the same from the outside. Measure which cost you’re actually paying, then choose knowing what each tool gives up.
What you’ll take away
The figures run on a 5,000-row activity feed and then a status dashboard, but those are only the worked examples. By the end you should be able to do all of this with whatever long list you work on.
- Tell from a performance recording why your list is slow, rendering work or the cost of holding rows in the page.
- Name the behaviors your list already gets from the browser, without a line of your code.
- Defer the offscreen rendering work with one CSS declaration, and supply the size placeholder that keeps the scrollbar accurate.
- Say what a virtualizer removes, what it makes you rebuild, and which of its numbers are measured rather than estimated.
- Rebuild deep links and per-row state as app code when you choose virtualization, and name what you’ll still have to build, like search that runs on the data model and focus continuity for rows that unmount.
- Decide per list which of the two answers is right, and defend it in review.
None of this needs a performance background or Lit experience, and there’s nothing to install. Every term gets explained the first time it matters, containment and the accessibility tree included, and the seven figures are for playing with rather than studying.
A quick note on letting AI code this
When you ask a model to make a long list fast, it hands you a virtualization library, wired correctly, in minutes, because that’s the best-known fix and at real scale the right one. Your judgment comes before the prompt, when you ask whether the measured cost is the size of the DOM or just the work of rendering it. When the prompt also names the contracts to keep, the same model builds the version that keeps find-in-page, deep links, focus, and per-card state working. The difference between the automatic answer and the measured one is judgment, and it matters more as the models get better at building whichever version you describe.
What a virtualizer actually removes
Virtualization has an intimidating name and a small idea under it. A virtualizer works out which rows would be on screen at the current scroll position and mounts only those, where mounting a row means creating it in the page. It adds a few extra on each side so a fast scroll can’t outrun the mounting. Those extra rows are the buffer. Everything outside that range still has to take up space, or the scrollbar would shrink to the height of the few rows that exist. As you scroll, the range slides, and rows are created on one edge and destroyed on the other. Virtualization is also called windowing, and you’ll meet both names in library docs.
Try that idea in the figure below. Scroll the list and watch the Elements panel beside it, the devtools view of the markup actually in the page. Rows appear and disappear while the tree never grows past one screen of rows, and the two spacer rows at its edges are what hold the missing height open. The drawer at the bottom switches the same feed between rendering every row and rendering only the rows that fit on screen, plus a few extra. The three counts in the drawer show the difference between the two settings. The data holds five thousand rows under either setting. While the list is windowed, the page holds only a dozen or so of them, which is a few dozen elements. Those numbers move with your scroll and list height, so the demo keeps them live. When every row is mounted, the page holds all five thousand rows, twenty thousand elements at four per row.
When you switch to “Every row rendered”, the panel stops changing while you keep scrolling, because nothing is being added or removed. That unchanging panel is what the plain version looks like from the browser’s side.
What each strategy actually costs
The chart below builds the same list three ways, plain, deferred, and virtualized, at 100, 1,000 and 10,000 cards, and times each run on your own machine. A production decision still needs a run on the slowest device you support, with your real rows.
Press the run button and compare the three bars at 10,000 cards. The deferred build comes in several times faster, but its bar still grows with the list, because every element is still built even when its rendering is skipped. The virtualized bar stays under a millisecond at every size, because it never builds the offscreen rows, and the sentence under the chart counts the elements to prove it. The bench, the code that builds and times each list, renders a simplified six-element card, so the bars compare strategies rather than card complexity. The rest of this article is about what that last bar costs you.
The contracts a list gets from the DOM
A long list that renders every row is quietly getting services from the browser.
Find-in-page indexes it, the tab order includes it, a link ending in #card-470 lands on a row inside it, and any state living in its elements persists because the elements are still there.
The table below leads with what everybody optimizes for, first paint, the moment the browser puts something other than blank space on screen.
| The contract | Naive assumption | Under content-visibility: auto | Under virtualization |
|---|---|---|---|
| Cheap first paint | render everything and hope | offscreen rendering work is deferred | only visible rows plus a buffer ever render |
| Small DOM in memory | the DOM costs whatever it costs | the full DOM stays parsed and in memory | the DOM holds tens of rows, not thousands |
| Find in page | always works | kept, skipped content stays findable | your job, unmounted rows can’t match |
| Focus and tab order | always works | kept, focusing a skipped card reveals it | your job, a focused row that unmounts drops focus |
| Anchors and scrollIntoView | always works | kept, though a distant jump can land off target | your job, via the virtualizer’s element API |
| Per-card state | lives in the DOM | kept, the node is never destroyed | your job, move it into the data model |
| Stable scrollbar | always works | needs a size placeholder you supply | estimated from the rows it has measured |
“Kept” doesn’t mean free, because a fully mounted list still holds every node in memory and produces a large accessibility tree, the parallel structure the browser builds from your DOM and hands to screen readers.
The “Small DOM in memory” row is where the two tools separate.
content-visibility lets the browser drop the rendering data it built for skipped cards, but the nodes themselves all stay.
Only virtualization shrinks the DOM.
The scrollbar row depends on an estimate in both columns, and each route below covers its own.
Measure before you optimize
Everything below builds one status dashboard and walks both routes on it. The dashboard is a Lit component that renders thousands of cards. In a real project you’d measure and follow one; the article follows both, because the choice is the subject. The figures switch between the versions live, so you can compare them on the same data.
Start with a plain Lit component that renders every card from a small data model, five fields describing one card.
The list itself comes from one directive, a Lit helper you call from inside a template to do something the template syntax can’t say on its own.
index.ts
// index.ts, the plain list
import { repeat } from 'lit/directives/repeat.js';
/**
* Renders the plain list, every card mounted.
*
* The list is keyed by `card.id` with `repeat()`, so each card keeps its own DOM node across re-renders, sorts, and filters, which is part of why per-card state survives here.
*/
private renderFlow() {
return html`
<div class="list">
${ repeat( this.cards, ( card ) => card.id, ( card ) => this.renderCard( card ) ) }
</div>
`;
}repeat() is Lit’s keyed list directive, and keying by card.id ties each DOM node to its data item, so a re-render, a sort, or a filter moves nodes instead of recycling them by position.
An index-keyed or unkeyed list pairs old nodes with new data by position.
After a sort, a card’s state stays in its old node while different data moves in, a second way to lose per-card state long before any virtualizer is involved.
The plain version is slow at large sizes, with every contract from the table intact. Before optimizing it, record it, because “the dashboard is slow” hides at least four costs, building the DOM, layout and paint, memory, and your own app logic. Your browser’s Performance panel, the devtools tab that records what the browser spent its time on, shows which cost you’re paying.
- If the recording shows long layout and paint work while the page holds a few thousand nodes, the DOM is fine and the rendering is expensive, so use the content-visibility route and keep every contract.
- If the DOM itself is the cost, because the browser holds tens of thousands of complex rows and mirrors every one into the accessibility tree, use the virtualization route and rebuild the contracts you need.
- If the recording points at your own code, neither route applies.
The content-visibility route
The least invasive optimization is two declarations on the card.
index.ts
/* index.ts, inside static styles */
.card {
content-visibility: auto;
contain-intrinsic-size: auto 120px;
}The first declaration lets the browser skip rendering for the cards nobody is looking at, and the second keeps the scrollbar accurate while they’re skipped. Both work through containment, so that idea comes first.
Containment means the card promises the browser that nothing inside it will affect the page around it. That promise is what makes skipping safe, because nothing the browser ignores inside a card can break the page outside. When a card is far from the screen, the browser skips its contents completely, so they aren’t painted, don’t react to clicks, and mostly don’t update styles. A skipped card also stops knowing how tall its contents are, and that’s why the second declaration hands it a height.
The catch is that the promise is enforced all the time, even while a card is on screen.
A tooltip, a dropdown, or a focus ring that used to spill outside the card now gets clipped at its edges.
z-index inside the card stops competing with the rest of the page, and a position: absolute child that used to anchor to some ancestor higher up now anchors to the card.
So the check before adding the property to an existing component is whether anything inside a card draws or positions itself outside its box.
Cards that keep everything inside their own box, like the ones here, are unaffected.
A card with a menu that reaches outside breaks immediately, a layout bug shipped alongside the speedup.
The browser skips a card only while it’s not “relevant to the user”, the CSS Containment spec’s own term for what must stay rendered. A card is relevant while it’s within about half a screen of the visible area, and also while anything inside is focused or selected. That last part is why this strategy keeps so many contracts, the browser reveals whatever the user reaches for.
The demo below renders the same 500 cards under both modes, with an identical node count, since this strategy never removes a node.
The skipped count is the browser’s own answer, checkVisibility() with its { contentVisibilityAuto: true } option, a built-in method that reports whether an element is actually being rendered.
The method reports the skip on the card’s contents, not on the card itself, which keeps its own box and stays visible.
So the demo calls it on an element inside the card, and the panel reports what the browser says while you flip the mode, re-render from scratch, and scroll hard.
A skipped card that no longer knows its height would collapse toward nothing and take the scrollbar with it; contain-intrinsic-size supplies the placeholder.
auto 120px is the remembered-size form, so once a card has rendered the browser records its real size and uses that recording the next time the card is skipped.
The bare form, contain-intrinsic-size: 120px, gets no such memory, so every skipped card falls back to the guess, even one whose real height the browser has already seen.
A card that starts offscreen has no recording yet and uses the fallback, which is where first visits and long jumps land.
One length covers both width and height, and percentages aren’t valid.
A skipped card is exactly as tall as you claimed, so the list’s total height is the sum of your guesses plus the real heights of the cards on screen. A bad guess means the scrollbar is drawn from a total that doesn’t match the content, and the mix of guessed and real heights shifts as you scroll, so the total moves while the reader drags the scrollbar.
The figure below runs 400 cards, some several times taller than others, so any single guess must be wrong for most of them. It records every total the list claims and reports the span between the smallest and largest, how far the number behind your scrollbar moved. The true error is at least that big, since the span only counts the guesses the browser has already fixed. The jump button shows the error more directly. A scroll to a distant card takes its target position from the current layout, including the guesses, and then real sizes replace those guesses while the browser scrolls toward it. What the figure reports is how far the browser had to correct that target. Chromium corrects the target and reaches the card; the CSS Working Group is still deciding how engines should handle it, and an engine that doesn’t correct would miss by roughly that distance.
Support is broad for both properties.
contain-intrinsic-size reached cross-engine support in 2023, and Baseline calls it Widely available, Baseline being the web platform’s shared label for how far a feature has spread across browsers and how long it’s been there.
content-visibility is the younger of the two, and Baseline counts it Newly available, which is fine for an optimization layered on top of a page that already works, exactly what this is.
Browser data updated September 24, 2026
Test the contracts you kept
Trust nothing, test the behaviors.
Find-in-page keeps working on skipped content because the spec requires it.
Skipped contents stay available to features like the find bar, and they stay focusable and selectable as normal.
Revealing a match is the browser’s job, the same way revealing a focused card is, so under content-visibility: auto your users’ Cmd/Ctrl F still finds a card the browser has never painted.
The history shows why “the spec requires it” isn’t enough on its own. Skipped content was findable in Chromium from the property’s 2020 debut and in Firefox from its 2024 release. Safari shipped the property in version 18 in September 2024 without that piece, and skipped content stayed invisible to its find bar until version 26 landed the fix a year later. Accessibility had an early gap of its own. Screen readers jump around a page by its landmarks, the labelled regions like navigation and main. Chrome 85 through 89 left the landmarks and headings inside skipped content out of the accessibility tree. Chrome 90 fixed it in 2021. MDN is explicit that skipped content stays in the accessibility tree. The spec is more cautious than the docs, though. css-contain-2 requires an engine to omit skipped content from the tree rather than run extra layout to expose it, because a page could notice that extra work and learn a screen reader is running. That requirement is a privacy protection. So two of the behaviors this strategy is praised for keeping have shipped broken in some browser, and the spec lets an engine leave one of them out. Don’t assume the behaviors survived. Test them with the browser and assistive-technology pairings your product supports.
The bench below turns that advice into a scoreboard.
After every strategy switch it re-checks whether card 470 is still in the document, whether its text is still there for find to match, whether a #card-470 link still has a target, and whether Tab can still reach it.
The card carries a phrase that appears nowhere else, so your find bar has exactly one match to report.
The fold-out holds the two checks a script can’t run for you.
No page is allowed to drive your find bar, and a #card-470 link followed inside the article would land on the article page rather than on this embedded figure.
So the fold-out copies the phrase and opens the bench alone in a fresh tab, where you search for yourself and watch where the fragment lands.
Run it under the first two strategies now.
We’ll come back to it for Virtualization.
If your recording pointed at rendering work rather than DOM size, you can stop here, with every contract still owned by the browser.
The virtualization route
Virtualization applies when the measurement says the DOM itself is the cost.
The tool is @lit-labs/virtualizer, a prerelease package from the Lit team, so pin the version you adopt and expect the API to move.
It mounts the rows near the viewport plus a buffer, creating and destroying row elements as the user scrolls, and the rest of the list simply isn’t in the page.
index.ts
// index.ts, the virtualized list
import '@lit-labs/virtualizer';
/**
* Renders the virtualized list through `<lit-virtualizer>`, as its own scroll container.
*
* The virtualizer mounts only the visible range plus a buffer, creating and destroying card elements as the user scrolls, and its default flow layout measures each mounted card with a ResizeObserver so variable heights need no configuration.
* The `scroller` attribute makes the virtualizer the scrolling element, the configuration where `element( index ).scrollIntoView()` jumps behave reliably.
*/
private renderVirtualized() {
return html`
<lit-virtualizer
class="scroll list"
scroller
role="region"
aria-label=${this.label}
tabindex="0"
.items=${ this.cards }
.renderItem=${ ( card: Card ) => this.renderCard( card ) }
.keyFunction=${ ( card: Card ) => card.id }
></lit-virtualizer>
`;
}.items is the data, .renderItem produces a row template, and .keyFunction does the same identity job as repeat()’s key.
The role, aria-label, and tabindex are deliberate, because a scrollable region needs to be focusable and named for keyboard and screen-reader users to reach it.
Row heights need no configuration, the virtualizer measures each mounted row and estimates the ones it hasn’t seen.
That estimate causes the same scrollbar drift as a wrong contain-intrinsic-size, except it’s automatic and self-correcting rather than yours to supply.
Ten thousand items produce only a couple of dozen rows in the page, since the library keeps about a thousand pixels of rendered content on either side of the view.
You’ll see it running when the contracts bench flips to Virtualization below, and in the scale lab at the end.
Rebuild what the DOM was providing
Go back to the contracts bench above and flip it to Virtualization.
The scoreboard drops from four contracts kept to none in one click.
Card 470 isn’t in the document, its text isn’t there for find to match, the #card-470 lookup returns null, and the tab-stop count collapses from 500 to the mounted handful.
The focus loss described at the start of the article happens here, and the bench can’t show it.
When the row holding focus unmounts, focus falls to document.body, a behavior discussed in the Lit repo, and nothing in the library redirects it.
None of those losses is a bug, and the library gives you the raw material to rebuild the contracts you need.
The jump below rests on one call, element( index ).scrollIntoView(), which scrolls to where the row will be and mounts it over the following frames.
The dashboard wraps that call and its waits into one API of its own, because a deep link has to be app code when the row it points at might not exist.
index.ts
// index.ts, the app-owned deep link
/**
* Scrolls to a card and moves focus onto it.
*
* The card's element may not exist yet, so `element( index ).scrollIntoView()` mounts and positions it first, and the focus move waits until the node is in the page.
* This method is the deep-link contract the app owns now that the browser no longer holds every row.
*
* @param index - The card index to reveal and focus.
* @returns Whether a card element was found and focused.
*/
async jumpToCard( index: number ): Promise<boolean> {
await this.updateComplete;
const $virtualizer = this.$virtualizer;
if ( ! $virtualizer ) {
return false;
}
$virtualizer.element( index )?.scrollIntoView( { block: 'center' } );
await this.settledLayout( $virtualizer );
const $card = await this.waitForCard( index );
if ( ! $card ) {
return false;
}
$card.scrollIntoView( { block: 'center' } );
$card.focus( { preventScroll: true } );
return true;
}What the browser used to do inside one scrollIntoView() call is now two asynchronous steps the app runs in order, scrolling first, then waiting for the mount before touching the node.
Both waits are capped by a timeout, because the virtualizer’s layout signal, layoutComplete, only resolves when a layout actually runs, and an open issue can leave a distant jump blank.
index.ts
// index.ts, the two bounded waits behind jumpToCard()
/**
* How long a jump waits for the virtualizer's next layout before assuming none is coming, in milliseconds.
*
* A jump that lands where the virtualizer already sits schedules no layout at all, so this is the bound that keeps repeat jumps from hanging, sized to a few frames of settling rather than to any documented guarantee.
*/
const LAYOUT_SETTLE_MS = 300;
/**
* How often `waitForCard()` re-checks for the mounted row, in milliseconds.
*/
const CARD_POLL_INTERVAL_MS = 50;
/**
* How many checks `waitForCard()` makes before giving up, a two-second bound with the interval above.
*
* Two seconds is deliberate headroom over the worst mount observed, because the virtualizer creates newly ranged rows over several frames and an open upstream issue can leave a distant jump blank; a timeout resolves to null and the caller reports `false` rather than throwing.
*/
const CARD_POLL_LIMIT = 40;
/**
* Awaits the virtualizer's next completed layout, bounded, because `layoutComplete` only resolves when a layout cycle actually runs.
*
* A jump that lands where the virtualizer already sits schedules no new layout, and awaiting the promise bare would hang there, so the bound keeps repeat jumps safe.
* The same promise rejects with `disconnected` when the virtualizer leaves the DOM, which is what unmounting the dashboard mid-jump does, and an unhandled `Promise.race` would turn that into a thrown jump instead of the documented `false`.
* Catching it on the layout arm keeps that contract, since the caller's next step already reports honestly when no card turns up.
* `layoutComplete` is also undefined until the element has built its internal virtualizer, and there is nothing to wait for in that window.
*
* @param $virtualizer - The mounted virtualizer to wait on.
* @returns A promise that settles on the layout, on that rejection, or on the bound, whichever lands first.
*/
private settledLayout( $virtualizer: LitVirtualizer ): Promise<unknown> {
const settled = $virtualizer.layoutComplete ?? Promise.resolve();
return Promise.race( [
settled.catch( () => undefined ),
new Promise( ( resolve ) => {
window.setTimeout( resolve, LAYOUT_SETTLE_MS );
} ),
] );
}
/**
* Resolves a card's element, polling briefly, because a virtualized jump mounts the row asynchronously.
*
* After a jump the virtualizer settles its scroll position first and creates the newly ranged rows over the following frames, so one `layoutComplete` is not yet the mounted node.
* A row that's already mounted resolves on the first attempt.
* The loop also gives up the moment the dashboard leaves the document, so a jump that outlives its own component stops polling instead of running the full bound.
*
* @param index - The card index to resolve.
* @returns The card's element, or null when it never mounted within the two-second bound.
*/
private async waitForCard( index: number ): Promise<HTMLElement | null> {
for ( let attempt = 0; attempt < CARD_POLL_LIMIT; attempt++ ) {
if ( ! this.isConnected ) {
return null;
}
const $card = this.renderRoot.querySelector<HTMLElement>( `[data-index="${index}"]` );
if ( $card ) {
return $card;
}
await new Promise( ( resolve ) => {
window.setTimeout( resolve, CARD_POLL_INTERVAL_MS );
} );
}
return null;
}State needs the same deliberate move.
If your code binds form state to the data model, as framework code usually does, that part is safe.
The risk is the state nobody wired, an input whose value exists only in the DOM, an open <details>, or a checkbox set with Lit’s ?checked binding, which only writes the starting value.
There’s also state you can’t wire at all, focus, text selection, a video’s playback position.
So the model gains two fields that must outlive a row, acked for each card’s acknowledgment checkbox and expanded for its fold-out detail section.
The component replaces the card and the array instead of editing them in place, because the virtualizer re-renders a mounted row from its new reference.
It announces each change with a card-change event so whoever owns the data can persist it.
index.ts, writing state where it survives
// index.ts, writing state where it survives
/**
* Replaces a card immutably, re-renders, and emits `card-change` with the updated card.
*
* Reassigning the array (never mutating in place) is what lets the change reach the page, since the virtualizer re-renders its mounted range from the new references.
*
* @param card - The card being updated.
* @param patch - The fields to change.
*/
private updateCard( card: Card, patch: Partial<Card> ): void {
const next = { ...card, ...patch };
this.cards = this.cards.map( ( c ) => ( c === card ? next : c ) );
this.dispatchEvent(
new CustomEvent<CardChangeDetail>( 'card-change', {
detail: { card: next },
bubbles: true,
composed: true,
} ),
);
}Each card in the demo below pairs a model-backed “Acknowledge” checkbox with a <details> element left unwired on purpose, so its open state lives only in the node.
On card 60, open the details, check the box, jump to the end, then come back.
Both survive under the first two settings; under Virtualization the details closes itself and the acknowledgment stays.
The app using the component finishes both rebuilds.
It writes each card-change back into its data and routes its URL fragment through the dashboard’s jump API, so a deep link still lands on its card.
ops-overview.ts, the app wiring the dashboard
// ops-overview.ts, the app wiring the dashboard
import { LitElement, html } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import './status-dashboard'; // registers <status-dashboard>
import type { StatusDashboard, Card, CardChangeDetail } from './status-dashboard';
import { makeCards } from './status-dashboard';
/**
* The page that owns the card data and wires the status dashboard into the URL.
*
* Deep links arrive as a `#card-<n>` fragment, and this page routes them through the dashboard's jump API.
* That routing is the piece the browser stops doing for you once a row might not exist.
*
* @element ops-overview
*/
@customElement( 'ops-overview' )
export class OpsOverview extends LitElement {
/**
* The dashboard below, resolved by Lit after the first render.
*/
@query( 'status-dashboard' )
private $dashboard!: StatusDashboard;
/**
* The card data this page owns, held as reactive state so a change written back re-renders the dashboard.
*
* The dashboard reports every change through `card-change`, which is the only thing that updates this array.
*/
@state()
private cards: Card[] = makeCards( 500 );
/**
* Resolves a `#card-<n>` fragment through the dashboard's jump API once connected.
*
* The browser can't resolve the fragment itself, the row may not exist, so the app owns the deep link and routes it here.
*/
connectedCallback(): void {
super.connectedCallback();
const match = window.location.hash.match( /^#card-(\d+)$/ );
if ( match ) {
// The await lets a disconnect land first, so re-check before reaching for the child.
void this.updateComplete.then( () => {
if ( this.isConnected ) this.$dashboard?.jumpToCard( Number( match[ 1 ] ) );
} );
}
}
/**
* Writes an acknowledged or expanded change back into this page's own copy of the data.
*
* The dashboard has already re-rendered itself, so this keeps the page's array in step and is where a persist call belongs.
*
* @param event - The dashboard's `card-change` event with the updated card.
*/
private handleCardChange = ( event: CustomEvent<CardChangeDetail> ): void => {
const changed = event.detail.card;
this.cards = this.cards.map( ( card ) => ( card.id === changed.id ? changed : card ) );
// send `changed` to the API here
};
/**
* Renders the dashboard with this page's data.
*/
render() {
return html`
<status-dashboard
.cards=${ this.cards }
@card-change=${ this.handleCardChange }
></status-dashboard>
`;
}
}The dashboard renders into a shadow root, the private DOM tree a web component keeps for its own markup. A raw URL fragment can’t target an id inside one under any strategy, because that’s how the platform works. So the fragment-to-API routing above is the right pattern for deep links into any shadow-DOM component. It’s also why the contracts bench renders its cards as plain document content, so a real fragment can behave differently per strategy.
The complete index.ts, with the properties, card template, and styles, sits in the code panel of the last figure, which is where to copy it from.
Its types.ts holds the card model, acked and expanded included.
One more idea belongs in the review, because “virtualization breaks accessibility” is a sloppy version of something true. The rows a virtualizer removes are removed for everyone, find-in-page and screen readers alike, and the rows it mounts can be perfectly accessible. What changed is ownership. The browser provided search, focus continuity, and navigation across every row because every row was in the document, and after virtualization those become product features you design, build, and test. Search against the data model is the next feature to build. Focus continuity for a focused row that unmounts is another, since the jump API only places focus after a deliberate jump.
A few ways this goes wrong
- A bare length that never remembers.
contain-intrinsic-size: 120pxkeeps feeding the scrollbar its guess forever, while theauto 120pxform corrects itself with remembered sizes. - Leaving state in the nodes because it always worked. It always worked because nothing ever destroyed the nodes. The unwired details element in the state demo fails exactly this way, and two minutes there teaches it better than an incident would.
- Optimizing before measuring. Virtualizing a few hundred cards means rebuilding search, focus, anchors, and state, all to remove a cost nobody measured. The Performance recording of the plain version is what justifies either tool.
What separates a senior from a junior here
- They ask which cost was measured before choosing a tool. “The list is slow, virtualize it” and “the list is slow, defer offscreen rendering” are different diagnoses that commit you to different work.
- They read an optimization as a change in contracts. The senior review asks whether anyone noticed that find-in-page, anchors, focus, and state stopped being the browser’s job and became ours, which is a more useful question than “faster is good”.
- They treat who owns a behavior and what it costs as separate questions. Deferring rendering keeps the contracts browser-owned and keeps the whole DOM in memory, while virtualization drops the DOM and the contracts together.
- They make state survival an architecture decision.
Model-backed
acked, an explicitcard-changeevent, and ajumpToCard()API are small, boring moves that stay correct on both routes, and they’re what makes the choice reversible later.
When to keep the DOM and when to drop it
Keep the DOM, with content-visibility: auto and an accurate placeholder, as long as the DOM isn’t the measured cost and the browser-owned behaviors matter.
Reach for a virtualizer when the rows are genuinely huge in count or cost, when memory and accessibility-tree size are the measured problem, and when the product can commit to owning search, jumps, focus continuity, and state as real features.
Scale usually decides which of those situations you’re in. At the contracts bench’s 500 cards, a virtualizer makes you rebuild four contracts for a problem two declarations solve. At 50,000, holding every card means about 550,000 elements, since a card is eleven of them, each with its own memory cost and its own share of the accessibility tree. Deferring keeps the page responsive there without removing a single element. Two hundred heavy cards can cost more than ten thousand plain rows, so treat these as reference points rather than thresholds.
The lab below is the finished component under a strategy selector and a card-count control. It waits behind a Run button because the heavy settings can freeze the tab, and the 50,000-card runs that keep every card mounted sit behind a second button inside the demo. The freeze you feel is the symptom, and a Performance recording says which part of mounting 50,000 cards produced it.
// index.ts
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { classMap } from 'lit/directives/class-map.js';
import '@lit-labs/virtualizer';
import type { LitVirtualizer } from '@lit-labs/virtualizer';
import type { Card, CardChangeDetail, RenderStrategy } from './types';
/**
* How long a jump waits for the virtualizer's next layout before assuming none is coming, in milliseconds.
*
* A jump that lands where the virtualizer already sits schedules no layout at all, so this is the bound that keeps repeat jumps from hanging, sized to a few frames of settling rather than to any documented guarantee.
*/
const LAYOUT_SETTLE_MS = 300;
/**
* How often `waitForCard()` re-checks for the mounted row, in milliseconds.
*/
const CARD_POLL_INTERVAL_MS = 50;
/**
* How many checks `waitForCard()` makes before giving up, a two-second bound with the interval above.
*
* Two seconds is deliberate headroom over the worst mount observed in this build's testing, because the pre-1.0 virtualizer creates newly ranged rows over several frames and an open upstream issue can leave a distant jump blank; a timeout resolves to null and the caller reports `false` rather than throwing.
*/
const CARD_POLL_LIMIT = 40;
/**
* A status dashboard that renders the same card list under three strategies, normal flow, `content-visibility: auto`, and `<lit-virtualizer>`.
*
* The strategy is a typed property so the choice stays a one-line, reversible decision, and the card state that must outlive a DOM node (`acked`, `expanded`) lives on the `Card` model rather than in the elements.
* Under `normal` and `content-visibility` every card stays mounted, so the browser keeps owning find-in-page, tab order, and scrolling to a card.
* Under `virtualized` only the visible range plus a buffer exists, so this component exposes `jumpToCard()` as the app-owned replacement for reaching a card that may not be in the DOM.
* One boundary belongs to the component itself rather than to any strategy.
* A raw URL `#fragment` resolves against the document tree, so an id inside this shadow root is never a target for one, which is one more reason deep linking is an explicit API here instead of a side effect of the markup.
*
* @element status-dashboard
* @fires card-change - Fired after a card's `acked` or `expanded` field changes, carrying the updated card so the data owner can persist it.
* @cssprop --dashboard-height - The height of the scrolling area. Defaults to `22rem`.
* @cssprop --dashboard-card-intrinsic - The `contain-intrinsic-size` placeholder used while a card is skipped under `content-visibility`. Defaults to `auto 120px`, the remembered-size form.
* @cssprop --status-warn-ink - The label color for a warning card. Defaults to the page's `--warning` mixed toward `--ink`, which holds 4.5:1 in either theme.
* @cssprop --status-error-ink - The label color for an error card. Defaults to the page's `--danger` mixed the same way.
*/
@customElement( 'status-dashboard' )
export class StatusDashboard extends LitElement {
/**
* Which rendering strategy the list uses.
*
* Reflected so a host page or test can read the active strategy off the element.
*/
@property( { type: String, reflect: true } )
strategy: RenderStrategy = 'normal';
/**
* The cards to render, owned by the host and updated here only through card interactions that also emit `card-change`.
*
* Updates are immutable (the array and the changed card are replaced), which both keeps the keyed normal-mode list stable and hands `<lit-virtualizer>` a new reference it re-renders from.
*/
@property( { attribute: false } )
cards: Card[] = [];
/**
* The accessible name of the scrolling region, announced with its `region` role.
*/
@property( { type: String } )
label = 'Status cards';
/**
* The mounted virtualizer while the `virtualized` strategy is active, otherwise null.
*
* Resolved by Lit from the shadow root; `jumpToCard()` uses it to reach items whose elements do not currently exist.
*/
@query( 'lit-virtualizer' )
private $virtualizer!: LitVirtualizer | null;
/**
* Styles for the dashboard, scoped to its shadow root.
*
* The `content-visibility` strategy lives in the `.defer .card` block, where `auto` defers offscreen rendering work while the `contain-intrinsic-size` placeholder keeps the scrollbar the right size before real sizes are known.
* The two status inks are mixed from the theme's own `--warning` and `--danger` toward `--ink`, so the same declaration clears 4.5:1 against the card surface in a light theme and in a dark one.
* A flat hex could only ever pass in whichever theme it was picked for.
*/
static styles = css`
:host {
display: block;
font-family: var( --font-sans, system-ui, sans-serif );
color: var( --ink, #17171a );
--status-warn-ink: color-mix( in srgb, var( --warning, #c2820c ) 75%, var( --ink, #17171a ) );
--status-error-ink: color-mix( in srgb, var( --danger, #d2483f ) 75%, var( --ink, #17171a ) );
}
*,
*::before,
*::after {
box-sizing: border-box;
}
.scroll {
height: var( --dashboard-height, 22rem );
overflow-y: auto;
border: 1px solid var( --line, #e7e6e2 );
border-radius: 10px;
background: var( --bg, #fcfcfb );
}
.scroll:focus-visible {
outline: 2px solid var( --accent, #2257e6 );
outline-offset: 2px;
}
.list {
display: block;
padding: 10px;
}
.defer .card {
content-visibility: auto;
contain-intrinsic-size: var( --dashboard-card-intrinsic, auto 120px );
}
/*
* The virtualizer absolutely positions each card, and with no explicit inset the card sits at the
* host's padding edge while width: 100% resolves against the full padding box, overflowing the
* scroller by exactly the left padding. Sizing virtualized cards to the content box removes the
* horizontal scrollbar and restores the right-hand gutter.
*/
lit-virtualizer .card {
width: calc( 100% - 20px );
}
.card {
border: 1px solid var( --line, #e7e6e2 );
border-radius: 8px;
background: var( --surface, #fff );
padding: 10px 12px;
margin: 0 0 10px;
width: 100%;
}
.card:focus-visible {
outline: 2px solid var( --accent, #2257e6 );
outline-offset: 2px;
}
.card header {
display: flex;
align-items: baseline;
gap: 8px;
}
.card h3 {
margin: 0;
font-size: 13.5px;
font-weight: 600;
flex: 1;
min-width: 0;
}
.idx {
font-family: var( --font-mono, monospace );
font-size: 11px;
color: var( --ink-faint, #71717b );
}
.status {
font-family: var( --font-mono, monospace );
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-ok .status {
color: var( --ink-faint, #71717b );
}
.status-warn .status {
color: var( --status-warn-ink );
}
.status-error .status {
color: var( --status-error-ink );
}
.card p {
margin: 6px 0 8px;
font-size: 12.5px;
line-height: 1.5;
color: var( --ink-soft, #56565c );
}
.card footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.ack {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var( --ink-soft, #56565c );
}
.more {
font: inherit;
font-size: 11.5px;
cursor: pointer;
padding: 4px 9px;
border-radius: 6px;
border: 1px solid var( --line, #e7e6e2 );
background: var( --bg, #fcfcfb );
color: var( --ink, #17171a );
}
.detail {
border-top: 1px dashed var( --line, #e7e6e2 );
padding-top: 8px;
}
`;
/**
* Renders the scrolling region and routes the list through the active strategy.
*
* Whichever element scrolls is focusable so keyboard users can scroll it, and it carries the accessible name from `label`.
* Under `virtualized` the virtualizer itself is the scroll container (the `scroller` attribute), which is the configuration where its jump API behaves reliably.
*/
render() {
if ( this.strategy === 'virtualized' ) {
return this.renderVirtualized();
}
return html`
<div class="scroll" role="region" aria-label=${this.label} tabindex="0">
${ this.renderFlow() }
</div>
`;
}
/**
* Scrolls to a card and moves focus onto it, under whichever strategy is active.
*
* Under `normal` the node exists, so this is a plain `scrollIntoView()` plus `focus()`.
* Under `content-visibility` the same calls work because focusing skipped content is a "relevant to the user" trigger, though a wrong `contain-intrinsic-size` estimate can make the first scroll stop short of the card until real sizes are known.
* Under `virtualized` the card's element may not exist, so the virtualizer's `element( index ).scrollIntoView()` mounts and positions it first, and the focus move happens after `layoutComplete`.
* This method is the deep-link contract the app owns once it renders through this component.
*
* @param index - The card index to reveal and focus.
* @returns Whether a card element was found and focused.
*/
async jumpToCard( index: number ): Promise<boolean> {
await this.updateComplete;
if ( this.strategy === 'virtualized' ) {
const $virtualizer = this.$virtualizer;
if ( ! $virtualizer ) {
return false;
}
$virtualizer.element( index )?.scrollIntoView( { block: 'center' } );
await this.settledLayout( $virtualizer );
}
const $card = await this.waitForCard( index );
if ( ! $card ) {
return false;
}
$card.scrollIntoView( { block: 'center' } );
$card.focus( { preventScroll: true } );
return true;
}
/**
* Awaits the virtualizer's next completed layout, bounded, because `layoutComplete` only resolves when a layout cycle actually runs.
*
* A jump that lands where the virtualizer already sits schedules no new layout, and awaiting the promise bare would hang there, so the bound keeps repeat jumps safe.
* The same promise rejects with `disconnected` when the virtualizer leaves the DOM, which is what a strategy flip during the settle window does, and an unhandled `Promise.race` would turn that into a jump that throws instead of returning the documented `false`.
* Catching it on the layout arm keeps that contract, since the caller's next step already returns `false` when no card turns up.
* `layoutComplete` is also undefined until the element has built its internal virtualizer, and there is nothing to wait for in that window.
*
* @param $virtualizer - The mounted virtualizer to wait on.
* @returns A promise that settles on the layout, on that rejection, or on the bound, whichever lands first.
*/
private settledLayout( $virtualizer: LitVirtualizer ): Promise<unknown> {
const settled = $virtualizer.layoutComplete ?? Promise.resolve();
return Promise.race( [
settled.catch( () => undefined ),
new Promise( ( resolve ) => {
window.setTimeout( resolve, LAYOUT_SETTLE_MS );
} ),
] );
}
/**
* Resolves a card's element, polling briefly, because a virtualized jump mounts the row asynchronously.
*
* After a jump the virtualizer settles its scroll position first and creates the newly ranged rows over the following frames, so one `layoutComplete` does not mean the row's node exists yet.
* Under the two keep-the-DOM strategies the first attempt resolves immediately.
* The loop also gives up the moment the dashboard leaves the document, so a jump that outlives its own component stops polling instead of running the full bound.
*
* @param index - The card index to resolve.
* @returns The card's element, or null when it never mounted within the two-second bound.
*/
private async waitForCard( index: number ): Promise<HTMLElement | null> {
for ( let attempt = 0; attempt < CARD_POLL_LIMIT; attempt++ ) {
if ( ! this.isConnected ) {
return null;
}
const $card = this.renderRoot.querySelector<HTMLElement>( `[data-index="${index}"]` );
if ( $card ) {
return $card;
}
await new Promise( ( resolve ) => {
window.setTimeout( resolve, CARD_POLL_INTERVAL_MS );
} );
}
return null;
}
/**
* Renders the normal-flow list, optionally deferring offscreen rendering with the `defer` class.
*
* The list is keyed by `card.id` with `repeat()`, so each card's DOM identity follows its data across re-renders, sorts, and filters, which is part of why state stored in the DOM survives here.
*/
private renderFlow() {
const classes = { list: true, defer: this.strategy === 'content-visibility' };
return html`
<div class=${ classMap( classes ) }>
${ repeat( this.cards, ( card ) => card.id, ( card ) => this.renderCard( card ) ) }
</div>
`;
}
/**
* Renders the virtualized list through `<lit-virtualizer>`, as its own scroll container.
*
* The virtualizer mounts only the visible range plus a buffer, creating and destroying card elements as the user scrolls, and its default flow layout measures each mounted card with a ResizeObserver so variable heights need no configuration.
* The `scroller` attribute makes the virtualizer the scrolling element, the configuration where `element( index ).scrollIntoView()` jumps behaved reliably in this build's testing.
*/
private renderVirtualized() {
return html`
<lit-virtualizer
class="scroll list"
scroller
role="region"
aria-label=${this.label}
tabindex="0"
.items=${ this.cards }
.renderItem=${ ( card: Card ) => this.renderCard( card ) }
.keyFunction=${ ( card: Card ) => card.id }
></lit-virtualizer>
`;
}
/**
* Renders one card, identical under every strategy.
*
* The card is a programmatic focus target (`tabindex="-1"`), its checkbox binds to the model's `acked` field, and the expandable detail drives the variable-height case.
*
* @param card - The card to render.
*/
private renderCard( card: Card ) {
return html`
<article
id=${card.id}
class="card status-${card.status}"
data-index=${card.index}
tabindex="-1"
aria-label="Card ${card.index}: ${card.title}"
>
<header>
<span class="idx">#${card.index}</span>
<h3>${card.title}</h3>
<span class="status">${card.status}</span>
</header>
<p>${card.body}</p>
<footer>
<label class="ack">
<input
type="checkbox"
.checked=${ card.acked }
@change=${ () => this.toggleAcked( card ) }
/>
<span>Acknowledged</span>
</label>
<button class="more" @click=${ () => this.toggleExpanded( card ) }>
${ card.expanded ? 'Hide details' : 'Details' }
</button>
</footer>
${ card.expanded ? html`<p class="detail">Raw feed: ${card.body}</p>` : nothing }
</article>
`;
}
/**
* Flips a card's `acked` field on the model and announces the change.
*
* The write goes to the data, and only incidentally to the DOM, because under `virtualized` the checkbox element is destroyed on unmount and the next mount renders whatever the model says.
*
* @param card - The card whose acknowledgment is toggling.
*/
private toggleAcked( card: Card ): void {
this.updateCard( card, { acked: ! card.acked } );
}
/**
* Flips a card's `expanded` field on the model and announces the change.
*
* @param card - The card whose detail section is toggling.
*/
private toggleExpanded( card: Card ): void {
this.updateCard( card, { expanded: ! card.expanded } );
}
/**
* Replaces a card immutably, re-renders, and emits `card-change` with the updated card.
*
* Replacing the array (never mutating in place) is what makes the update visible to both render paths: the keyed `repeat()` reuses the same DOM node, and the virtualizer re-renders the mounted range from the new reference.
*
* @param card - The card being updated.
* @param patch - The fields to change.
*/
private updateCard( card: Card, patch: Partial<Card> ): void {
const next = { ...card, ...patch };
this.cards = this.cards.map( ( c ) => ( c === card ? next : c ) );
this.dispatchEvent(
new CustomEvent<CardChangeDetail>( 'card-change', {
detail: { card: next },
bubbles: true,
composed: true,
} ),
);
}
}
/**
* Re-exported so this file is the component's single public entry, and a consumer imports the element, the dataset factory, and the types from one place.
*/
export { makeCards, FIND_PHRASE } from './data';
export type { Card, CardChangeDetail, RenderStrategy, StatusLevel } from './types';
declare global {
interface HTMLElementTagNameMap {
'status-dashboard': StatusDashboard;
}
}One product question belongs next to the performance one, since “the list must hold 50,000 rows in one scroller” is itself a decision to review. The Baymard Institute’s usability research found “load more” patterns beat endless scrolling and pagination for real users, and a shorter list avoids the problem entirely. When the product truly needs one continuous list, finding one row in fifty thousand carries a real interaction cost, so the search and jump contracts you now own can’t be afterthoughts.
Both answers are correct somewhere, and the questions below tell you which one your list needs.
- Which cost did we measure, DOM creation, layout and paint, memory, or app logic?
- Does any product feature depend on every row existing in the page, or is the full DOM only there to paint this view?
- Which browser contracts does this list currently get from the DOM?
- Which of those are real user-facing requirements?
- Which optimization keeps them?
- Which contracts would we need to rebuild, and who would build and maintain them?
- Is the performance gain worth taking that ownership?
The next slow screen you meet will probably be a table, a chat log, or an infinite feed rather than a dashboard, and every question above transfers to it unchanged. Measure which cost you’re paying, name the behaviors the DOM is quietly providing, and decide, per behavior, whether the browser keeps owning it or you do, on purpose this time.
Want more of these in your Google results?