Measuring INP in Virtualized Lists
Virtualization exists to keep a long list cheap by rendering only what is visible. It succeeds at that and then produces a different problem: every scroll, filter and sort becomes a burst of synchronous DOM work tied directly to a user interaction, which is precisely what INP Tracking & Debugging measures. This guide covers how to instrument the interactions a virtualized list actually generates, which of them the metric sees, and what to change when the p75 is bad.
The surprise for most teams is that the scroll itself is rarely the problem. Scrolling is not a qualifying interaction for INP — but the click that opened the filter panel, the keystroke in the search box that re-ran the windowing calculation, and the tap that expanded a row all are.
Prerequisites
- A list implementation you can hook — either your own windowing code or a library that exposes render callbacks.
- Event Timing observation in place, with
interactionIdcaptured, as covered in Web Vitals API Implementation. - A route where the list is the primary content, so the field data is not diluted by other screens.
- A device profile that matches your worst cohort — 4× CPU throttling approximates mid-tier Android closely enough to iterate against.
How to measure and fix it
Step 1 — Instrument the interactions the list generates
// list-metrics.js — measure the interactions a virtualized list produces,
// grouped by what the user did rather than by which element they hit.
const KINDS = {
'filter-input': 'filter',
'sort-header': 'sort',
'row-expand': 'expand',
'load-more': 'paginate',
};
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.interactionId) continue; // not a qualifying interaction
const el = entry.target;
const kind = el?.closest?.('[data-list-action]')?.dataset.listAction;
navigator.sendBeacon('/rum', JSON.stringify({
metric: 'list_interaction',
value: Math.round(entry.duration),
kind: KINDS[kind] || 'other',
// Row count at the moment of the interaction: the single strongest
// predictor of how long the work took.
rows: window.__listState?.total ?? 0,
rendered: window.__listState?.rendered ?? 0,
route: window.__route,
}));
}
}).observe({ type: 'event', durationThreshold: 40, buffered: true });
Recording the row count alongside the duration is what turns this from an anecdote into a model. A list that is fine at 200 rows and unusable at 20,000 has a complexity problem, not a constant-factor one, and the two demand different fixes.
Step 2 — Separate the three costs inside the interaction
A slow list interaction is nearly always one of three things, and they are distinguishable by measurement rather than by inspection.
| Cost | How to spot it | Typical fix |
|---|---|---|
| Data work (filter, sort, group) | Latency scales with total rows | Index or memoize; move to a worker |
| Windowing calculation | Latency scales with rendered rows | Cache measurements; avoid per-row layout reads |
| DOM churn | Latency scales with rows added or removed | Reuse nodes; avoid full re-render on state change |
// Wrap each phase so the split shows up in field data, not just in a profile.
function timed(label, fn) {
const t0 = performance.now();
const result = fn();
performance.measure(`list:${label}`, { start: t0, end: performance.now() });
return result;
}
function onFilterChange(query) {
const matched = timed('data', () => index.search(query));
const window_ = timed('window', () => computeWindow(matched, scrollTop));
timed('render', () => renderRows(window_));
}
The performance.measure entries flow into the same pipeline as everything else through the User Timing collector described in User Timing API: Marks & Measures.
Step 3 — Fix the data work first
Filtering twenty thousand rows on every keystroke is the most common cause and the easiest to remove. Three changes, in order of effect:
Debounce the expensive part, not the input. Update the input value synchronously so typing feels instant, and schedule the filter for the next idle slot. The interaction ends at the paint of the character, not at the completion of the filter.
Index once, query cheaply. Building a lowercase token index when the dataset loads turns a per-keystroke full scan into a map lookup. The build cost is paid once, off the interaction path.
Move the scan to a worker when the dataset is large. Above roughly ten thousand rows, the copy cost of postMessage is smaller than the scan it replaces, and the main thread stays free for the paint.
// Keep the interaction short: paint the keystroke, then do the work.
input.addEventListener('input', (e) => {
const query = e.target.value;
renderQueryText(query); // cheap, synchronous, ends the interaction
scheduleFilter(query); // expensive, off the interaction
});
let pending = null;
function scheduleFilter(query) {
if (pending) cancelIdleCallback(pending);
pending = requestIdleCallback(() => {
worker.postMessage({ type: 'filter', query });
}, { timeout: 200 });
}
Step 4 — Yield inside the render when it is unavoidable
Some interactions genuinely have to touch many nodes. Breaking the work so the browser can paint between chunks is what keeps the measured interaction short — the mechanics are in Breaking Up Long Tasks with scheduler.yield.
async function renderRowsChunked(rows) {
const CHUNK = 24;
for (let i = 0; i < rows.length; i += CHUNK) {
renderChunk(rows.slice(i, i + CHUNK));
if (i + CHUNK < rows.length) {
await (scheduler.yield?.() ?? new Promise((r) => setTimeout(r, 0)));
}
}
}
Step 5 — Stop reading layout inside the loop
The quietest cost in a virtualized list is forced synchronous layout: reading offsetHeight or getBoundingClientRect() on each row after writing to the DOM. Each read flushes pending layout, so a loop that alternates write and read runs layout once per row. Measure once before the loop, or store known row heights, and the same code runs an order of magnitude faster with no change to what it produces.
Verifying it works
- p75 by interaction kind should show the filter and sort kinds dropping below 200 ms. If one kind is still slow, the fix has not reached that path.
- Latency against row count should flatten. A line that is still linear in total rows means something is still scanning everything.
- Long Animation Frames during list interactions should show no single script over about 50 ms.
- Session INP for the route should follow within a few days. It lags because it is a session maximum, as explained in Soft Navigations & SPA Metrics.
Budgeting a list interaction before you build it
Retrofitting is harder than choosing sensible limits up front. Three numbers are worth deciding before the first implementation, because each one caps what the interaction can cost.
Maximum rendered rows. The window should be the visible count plus a small overscan — typically eight to twelve rows either side. Overscan beyond that buys nothing perceptible and multiplies the DOM work on every scroll-driven update.
Maximum synchronous data rows. Any operation that runs inside an interaction should touch a bounded number of rows. Above that bound it belongs behind an index, a worker or an idle callback. Two thousand is a reasonable starting bound on a mid-tier device for simple string matching, and far lower for anything involving object allocation.
Maximum row complexity. A row with twelve nested elements and three conditional branches costs roughly three times a flat one to create and to style. Where a list is known to be long, keeping the row template shallow is worth more than any windowing optimisation applied afterwards.
Writing these into the component contract makes them testable, which is how they survive the third feature request. The wider budget framing is in Performance Budgets & SLOs.
Edge cases and gotchas
- Scrolling is not a qualifying interaction. A janky scroll does not appear in INP at all. It matters to users, so measure it separately with Long Animation Frames rather than expecting the metric to show it.
- Keyboard navigation generates many interactions quickly. Holding an arrow key produces a stream of qualifying interactions, and INP takes the worst. A list that is smooth to scroll can be terrible to keyboard through.
content-visibility: automoves cost, it does not remove it. Rows skipped during layout are laid out when they scroll into view, which can land inside an interaction.- Sticky headers force layout. A header that repositions on scroll reads layout on every frame and competes with the interaction for the same budget.
- Row height caches go stale. If a cached height is wrong after a font loads or an image decodes, the list jumps — a CLS problem on top of an INP one, covered in CLS Reduction Strategies.
- Library internals are still your latency. Using a windowing library does not move the responsibility; instrument it the same way you would your own code.
FAQ
Why does INP look fine in the lab and bad in the field?
Because a lab run uses a small fixture dataset and a fast machine. The relationship between row count and latency is the thing to test, and a fixture of fifty rows tells you nothing about twenty thousand on a mid-tier phone.
Should I debounce the input itself?
No — debouncing the visible value makes typing feel broken. Update the input synchronously and defer only the expensive filter work behind it.
Is a web worker worth the complexity?
Above roughly ten thousand rows, usually yes: the structured-clone cost is smaller than the scan it replaces. Below that, indexing and deferring on the main thread is simpler and gets most of the win.
Does virtualization help INP at all, or only memory?
It helps both, but indirectly: fewer rendered nodes means cheaper style, layout and paint per interaction. It does nothing for data-side work, which is where most slow list interactions actually are.
Related
- INP Tracking & Debugging — the metric, its bands and the capture pattern behind this guide.
- Breaking Up Long Tasks with scheduler.yield — chunking render work so the browser can paint.
- Long Task & Main-Thread Attribution — seeing the blocking work that INP alone does not show.