Prompt and applicable scenarios
A product activity feed can grow to 100,000 logical items. Its cursor API returns 50 items per request. A card may be 48 to 240 pixels high after text wraps, an image loads, or details expand. Users can filter the feed, expand cards, and draft inline replies. Keyboard and screen-reader users must be able to move through the feed without losing focus or position information.
Design the rendering range, variable-height measurement, incremental loading, state ownership, scroll anchoring, focus behavior, accessibility semantics, and validation plan. Explain when a simpler paginated or non-virtualized list is the better choice.
The item count, page size, and height range are interview assumptions, not universal product targets. This question fits senior frontend, UI infrastructure, and Web performance roles. Its core skill is browser rendering and interaction design, so the category is frontend.
What the interviewer evaluates
First, can the candidate separate four concerns that are often mixed together? Pagination controls which records the client has loaded. Virtualization controls which loaded records have DOM nodes. Stable identity controls which business record owns state. Accessibility controls how a partial DOM represents the logical collection. One mechanism does not solve the other three.
Second, can the candidate derive range math from row-size assumptions? Equal-height rows allow direct offset arithmetic. Variable-height rows need estimates, measured sizes, cumulative offsets, and a correction policy when an estimate changes. A strong answer names the scroll jump caused by changing content above the viewport and preserves a logical anchor.
Third, can the candidate avoid state corruption? A recycled visual slot is not a business record. Drafts, selection, expansion, and pending mutations need stable record IDs and state that survives a row being unmounted. Sorting, filtering, and stale page responses must not attach data or state to the wrong index.
Fourth, can the candidate keep accessibility intact? Only mounting visible cards removes most of the logical set from the DOM. A strong answer preserves native or feed semantics, exposes logical position and set size, announces busy state correctly, keeps the focused card mounted, and provides an operable loading path.
Fifth, can the candidate validate behavior instead of promising “smooth scrolling”? The answer should define target devices and scenarios, then measure mounted-node count, frame and long-task behavior, layout shifts or anchor error, request duplication, memory, focus retention, and screen-reader output.
Questions to clarify first
- Is the list truly slow without virtualization? If the product shows at most a few dozen simple rows, normal rendering or pagination is easier to maintain and more accessible. Measure before adding windowing.
- Are row heights fixed, bounded, or arbitrary? Fixed heights permit constant-time range math. Variable heights require estimates and measurement. Unbounded embeds or late media make scroll correction harder and may justify constrained card layouts.
- Does the API expose a total count or only a next cursor? A known total can support a full logical set size. An unknown total changes scrollbar expectations and accessibility metadata. Cursor pagination also means unloaded indexes are not directly addressable.
- Must users jump to an arbitrary item? Jumping by index with variable, mostly unmeasured rows is approximate until nearby rows are measured. Jumping by record ID may require a server lookup or different API.
- Which row state must survive unmounting? Temporary hover can disappear. Draft replies, selection, expansion, validation errors, and optimistic mutations usually belong in state keyed by record ID outside the row component.
- What keyboard model and semantics apply? A reading feed, selectable listbox, and editable grid have different roles and keyboard contracts. Choose the semantic pattern from the interaction, not from the visual layout.
- What is the scroll contract when data changes? After filtering, the product may reset to the top. When older items are prepended, it usually preserves the current first visible item. When live items append, it follows only if the user was already at the end.
- What are the performance acceptance targets? Name target devices, data shape, scroll actions, and budgets. A desktop trace on tiny placeholder rows does not validate a mobile feed with images and controls.
30-second answer framework
“I would keep data loading, virtualization, and row state separate. The cursor loader fetches and deduplicates 50-item pages. The virtualizer renders only the visible range plus a small overscan and positions those rows inside a spacer whose height represents the loaded data. Fixed rows use offset arithmetic; variable rows start with estimates, are measured after layout, and update cumulative offsets. When a height before the viewport changes, I preserve the first visible record and its intra-row offset so the screen does not jump. Every row uses the record ID, while drafts and selection live outside the row. For an activity feed, I would expose feed and article semantics, logical position and set size, busy state, and never unmount the card containing focus. I would test fast scroll, image and expansion changes, filter races, prepend and append behavior, keyboard and screen-reader navigation, node count, long tasks, memory, and anchor error. If ordinary rendering already meets the budget, I would keep the simpler list.”
Step-by-step deep dive
Step 1: Separate loaded records from mounted rows
Maintain explicit state for the data layer: ordered record IDs, a map from ID to record, the next cursor, request status, and whether more data exists. The virtualizer receives the ordered loaded IDs and a scroll container. It does not own cursors, retries, drafts, or server mutations.
With a cursor API, initially only 50 records are addressable. Virtualize the loaded prefix and grow its logical height as pages arrive. Do not pretend that unloaded index 73,421 is locally available unless the API supports indexed access or returns placeholders with stable positions. The visible range can trigger a fetch when its end approaches the loaded count, but the loader remains responsible for coalescing requests and stopping at the end.
This separation prevents two common bugs: infinite loading that leaves thousands of old DOM nodes mounted, and a virtualizer that fires duplicate requests every time its small range is recalculated.
Step 2: Derive the fixed-height range before adding variable heights
For equal-height rows, derive the first and last visible indexes from scrollTop, viewport height, and row height. Overscan expands the range but is clamped to the loaded count. A full-height inner canvas creates the scrollbar; mounted rows are positioned at their logical offsets.
function getFixedRange({
scrollTop,
viewportHeight,
rowHeight,
loadedCount,
overscanRows,
}) {
const visibleStart = Math.floor(scrollTop / rowHeight);
const visibleEnd = Math.ceil(
(scrollTop + viewportHeight) / rowHeight,
);
return {
start: Math.max(0, visibleStart - overscanRows),
end: Math.min(loadedCount, visibleEnd + overscanRows),
totalHeight: loadedCount * rowHeight,
};
}Render the half-open interval from start through end, excluding end. A row at index i begins at i * rowHeight. Update the range from the scroll container, batch rendering with the framework, and avoid reading layout and writing styles repeatedly inside one scroll callback.
This solution is preferable when the design can enforce one row height or a small number of known variants. It is predictable, cheap, and easy to restore. Do not adopt dynamic measurement merely because the library offers it.
Step 3: Measure variable rows and maintain cumulative offsets
Variable rows replace multiplication with a size model. Start each unmeasured row with a reasonable estimate based on the actual card design. Keep measured sizes by record ID, not by a recycled DOM slot. The start offset of an item is the sum of preceding sizes, so finding the first visible item becomes a search over cumulative offsets.
A simple prefix array is easy to explain but can require many later offsets to be updated when one height changes. A virtualizer library or an indexed prefix-sum structure can reduce search and update work. The important interview decision is the invariant: every rendered row has a logical start offset derived from the same size store, and the total canvas height equals the sum of current measured or estimated sizes.
Use ResizeObserver or the library's measurement hook after a row lays out. Reserve image space from known dimensions when possible; otherwise an image load produces a new measurement. Ignore unchanged sizes, batch corrections, and disconnect observers for rows that unmount. Re-measure when width, font metrics, expansion, or content changes can alter height.
Estimates are allowed to be wrong. The system fails when it changes offsets without a policy for what the user should keep looking at.
Step 4: Preserve a logical scroll anchor
Before applying size changes, capture the first visible record ID and the distance from its top edge to the viewport's top edge. After updating measurements and cumulative offsets, find that record's new start and set the scroll offset so the same intra-row point stays in the same visual location.
If a row entirely above the anchor grows by 30 pixels, the scroll offset normally needs the same 30-pixel correction. A change below the anchor does not. If the user is dragging the scrollbar or the product deliberately navigated to a new item, defer or skip correction according to that interaction contract.
Browsers also implement scroll anchoring for ordinary layout changes. A virtualizer frequently uses a synthetic canvas and absolutely positioned rows, so native behavior may not preserve the intended logical item. Test the combination. Let either the browser or the virtualizer own compensation for a given container; if manual anchoring is authoritative, opt out of native anchoring there to avoid double correction.
Prepending older records uses the same rule. Capture the current anchor, insert and measure the new records, then restore the anchor. Appending live records follows the end only when the user was already within the product's “at latest” threshold; otherwise show a “new activity” action instead of moving the viewport.
Step 5: Tune overscan from work per row and scroll behavior
Overscan hides rendering latency by mounting items just outside the viewport. Too little can expose blank space during fast scroll; too much recreates the DOM, layout, memory, and effect cost virtualization was meant to avoid. With variable heights, pixel-based overscan is often more stable than a fixed row count because five compact rows and five expanded rows represent different work and distance.
Start conservatively, record traces on target devices, and tune from evidence. Consider scroll direction, velocity, row rendering cost, image decoding, and the framework's update latency. Overscan is not a substitute for memoizing expensive row work, reserving media dimensions, cancelling obsolete effects, or reducing synchronous layout reads.
Keep a strict node-count invariant: mounted rows equal the visible range, overscan, and a small number of explicitly pinned rows such as the focused item. A memory profile should show old row components, observers, and event listeners becoming collectible after they leave that set.
Step 6: Load pages without races or duplicate records
Trigger loading from the logical range end instead of relying on a sentinel that may be recycled away. Allow at most one request per cursor. Store the cursor associated with each request, deduplicate returned records by stable ID, and stop when the server says there is no next cursor.
Filtering or sorting creates a new query generation. Abort the previous request when possible and reject any late response whose generation no longer matches. Reset ordered IDs, cursor, measurements that depend on layout or content, and the scroll position according to the product contract. Reusing old offsets for a new order attaches the correct heights to the wrong positions.
Loading placeholders need stable logical treatment. If a single placeholder stands for the next page, it is not 50 accessible records. If the API supplies a known indexed total and the product chooses per-row placeholders, give them stable positions and replace them without shifting identity. Always expose retry and end-of-list states; a failed fetch must not leave a permanent blank tail that looks like the end.
Step 7: Keep record identity and important state outside recycled rows
Use each record's stable ID as the framework key and the virtualizer's item key. Array index is unsafe when pages prepend, filters reorder records, or duplicates are removed. A recycled slot may display record A now and record B later; state tied to the slot can leak a draft, checkbox, or optimistic result between users' records.
Store durable UI state as maps keyed by record ID: draft text, expansion, selection, validation status, and pending mutation. A row reads its slice when mounted and writes through an owner that survives unmounting. Local state is acceptable for disposable presentation details whose loss is intentional.
Do not retain every transient value forever. Remove state when the product discards a query, bound draft retention, and reconcile optimistic state with server results. The goal is ownership by business identity, not an unbounded client cache.
Step 8: Design accessibility as part of the range algorithm
Choose semantics from the interaction. For a reading feed, the container can use feed semantics and each card article semantics with an accessible name. Each mounted article exposes its one-based logical position. If the total is known, expose the logical total; if it is unknown, use the pattern's unknown-size representation. Mark the feed busy during multi-operation updates and always clear that state when the update finishes.
Do not unmount the element that contains DOM focus. Add the focused record to the mounted range even when it falls outside normal overscan, or move focus through a deliberate navigation action before eviction. When keyboard commands move to the next logical article, ensure it is loaded and mounted, scroll it into view, then focus it. Document any custom keys and preserve a reliable way to move before and after the feed.
Virtualization can also break find-in-page, browser text selection, printing, and screen-reader browse behavior because off-screen content is absent from the DOM. Decide whether those features are requirements. Search may need a server-backed control, printing may render a separate paginated view, and an explicit “Load more” control can provide an operable alternative to automatic loading.
Finally, test with keyboard-only operation and actual screen readers. Inspecting attributes confirms markup but cannot prove that focus, announcements, loading, and navigation work together.
Step 9: Validate a failure matrix, not one smooth scroll
Build a deterministic data set containing short and long text, delayed images with dimensions, expansion, missing media, and duplicate IDs. Exercise slow and fast wheel scroll, touch-like momentum, scrollbar dragging, resize, zoom, font load, filter changes during a request, prepend, append, fetch failure, retry, and end-of-data.
Record mounted row and DOM node counts across the run. Use a performance trace to inspect frames, long tasks, scripting, layout, paint, and unexpected repeated measurement. Track peak and retained memory after scrolling through many pages. Compare the top logical anchor before and after height changes, and assert that duplicate cursors and IDs do not appear.
For interaction, place focus in a row, scroll with pointer and keyboard, expand rows above it, load the next page, and change filters. Verify where focus lands and whether drafts remain attached to the correct IDs. With a screen reader, verify article names, logical position and set size, busy transitions, next and previous navigation, retry, and the end state.
The acceptance target belongs to the product and target devices. Report measured traces and invariant violations rather than claiming that virtualization guarantees a particular frame rate.
High-quality sample answer
“I would model the feed as three layers. The data layer owns ordered IDs, records, the next cursor, request generations, and deduplication. The virtualizer receives only loaded IDs and owns the visible range, measurements, and positioning. Drafts, expansion, selection, and mutations live in maps keyed by record ID so unmounting a row does not delete or move them.
For fixed rows, the range is direct offset arithmetic and the canvas height is loaded count times row height. These cards vary from 48 to 240 pixels, so I would start from estimates, measure mounted cards, and maintain cumulative offsets. The first visible record and its intra-row offset form the scroll anchor. When a measurement above that record changes, I update the scroll offset by the resulting delta. I would choose one compensation owner for the container so native and manual scroll anchoring do not both adjust it.
The range includes a small measured overscan plus any focused card. When its end approaches the loaded count, the loader requests the next cursor once. A filter change starts a new generation, cancels the old request where possible, rejects late results, and resets order and measurements. Record IDs serve as item keys; indexes never own state.
For accessibility, this activity stream uses feed and article semantics. Mounted articles expose their logical position and known total, the feed exposes busy state while pages are inserted, and that state is cleared afterward. I do not unmount the article containing focus. Keyboard navigation loads and mounts the next logical article before focusing it, and I keep explicit retry, end, and load-more paths.
I would first confirm that ordinary rendering fails the target budget. Then I would test a deterministic mixture of row heights, delayed media, expansions, fast scrolling, resizing, filter races, prepend and append, and network failures. I would record mounted-node count, long tasks, layout and paint work, retained memory, anchor error, duplicate requests and IDs, draft identity, keyboard focus, and screen-reader output. If a paginated normal list meets the same requirements, I would choose it because it has fewer correctness and accessibility failure modes.”
Common mistakes
- Treating infinite loading as virtualization → old pages keep adding DOM nodes and performance degrades with distance scrolled → bound the mounted range independently from the loaded data.
- Using array indexes as item keys → prepend, filtering, and deduplication move drafts and selection to different records → key rows and external state by stable record ID.
- Assuming one average height is exact → offset error accumulates and causes blank ranges or jumps → measure variable rows and update one cumulative size model.
- Applying measurement changes without an anchor → content above the viewport moves what the user is reading → preserve the first visible record and its intra-row offset.
- Letting native and manual anchoring both compensate → the viewport can move twice → test the container and assign compensation to one owner.
- Overscanning a fixed large row count → expanded cards recreate heavy DOM and layout work → tune pixel distance and rendering cost on target devices.
- Firing a fetch from every range update → repeated renders request the same cursor and interleave results → coalesce by cursor and reject stale query generations.
- Keeping drafts inside row components → unmounting deletes them or slot reuse leaks them → lift important state into an ID-keyed owner.
- Unmounting the focused row → keyboard and assistive-technology context disappears → pin the focused item or move focus deliberately before eviction.
- Reporting only the mounted subset's position → a screen reader hears misleading collection size and order → expose logical position and known or unknown logical set size.
- Setting busy state and never clearing it → assistive technology may not expose completed updates → clear busy state in every success and failure path.
- Optimizing only frame rate → focus, drafts, races, memory, and scroll position can still be wrong → validate performance and interaction invariants together.
Follow-up questions and responses
Follow-up 1: What changes if every row is exactly 56 pixels high?
Remove dynamic measurement and its correction paths. Compute range and offsets directly from 56 pixels, derive total loaded height by multiplication, and restore positions by index plus intra-row offset. Keep stable IDs, loading races, focus, and accessibility handling; fixed height simplifies geometry but does not solve identity or interaction. Benchmark before adding a prefix-sum structure that the constraint no longer needs.
Follow-up 2: How do you support an unknown total from a cursor-only API?
Virtualize the loaded prefix and grow the canvas when a page arrives. Do not invent unloaded indexes or a fake final scrollbar size. Expose the feed pattern's unknown set size, announce loading and end states, and keep an explicit load-more or retry control. Arbitrary jump-to-index is unavailable unless the server adds indexed lookup, a record locator, or a stable estimate contract.
Follow-up 3: Older items are prepended while the user reads the middle. How do you stop the jump?
Capture the first visible record ID and its viewport-relative offset before insertion. Insert the older IDs, estimate or measure them, recompute cumulative offsets, and set the scroll offset so the captured record returns to the same visual point. Deduplicate IDs at the page boundary. If measurement continues after media loads, apply later deltas above the anchor using the same rule.
Follow-up 4: The focused card is 5,000 items away after the user drags the scrollbar. Do you keep it mounted forever?
Define the interaction contract. Keeping one focused card pinned is a bounded DOM cost, but a hidden focus target far from the viewport can confuse users. During an intentional scrollbar jump, move focus to the feed container or the newly navigated visible article with an announcement, then unpin the old card. During incidental measurement or pointer scroll, do not silently destroy focus. Test the chosen behavior with keyboard and screen-reader users.
Follow-up 5: Product asks for browser find-in-page and printing all 100,000 items. Can one virtualized DOM satisfy that?
No reliable partial DOM can expose text that is not mounted to browser find or print it as ordinary page content. Treat these as separate requirements. Provide server-backed search that moves to and mounts the matching record. Generate a paginated export or print-specific data view with explicit limits instead of temporarily mounting 100,000 interactive cards and freezing the page.
Follow-up 6: How would you test a virtualizer without relying only on screenshots?
Test the pure fixed-range and cumulative-offset invariants with deterministic inputs, then run integration scenarios in a real browser. Assert that the mounted IDs cover the visible range and overscan, total size matches the size model, the logical anchor stays within the agreed tolerance after measurement, each cursor is requested once, and drafts follow IDs through reorder. Add keyboard and screen-reader checks because DOM geometry assertions cannot validate focus and announcements.