cswork / libraryOpen practice workspace

Articles

Virtualized List Coding Interview: Variable Heights, Scroll Anchoring, and Accessibility

Practice a variable-height virtualized list interview with measured rows, scroll-anchor math, stable IDs, accessibility checks, and a runnable example.

A list with 50,000 messages is easy to optimize if every row is 60 pixels tall. The interview gets harder when images load, text wraps, new messages arrive above the viewport, and a keyboard user has focused a row that the virtualizer wants to remove.

In a virtualized list coding interview, a strong answer keeps three things correct at once: which rows render, which content stays under the user's eye, and which item owns keyboard focus. A faster DOM is not useful if the view jumps or a focused control disappears.

The numbers and code below form an original practice exercise. TanStack Virtual's Virtualizer API documents the real library controls used as reference. The small calculation function was run locally; the complete browser and assistive-technology behavior still needs UI testing.

The task the interviewer gives you

Build a message list inside a 480-pixel-high scrolling pane. Each message has a stable server ID. Some are short, some expand after an image loads, and the user can prepend older messages. Keep the number of mounted rows small. Preserve the reader's place when content above the visible row changes. Ensure keyboard users can continue from their focused message.

Before coding, ask four questions. Can messages reorder or only prepend and append? Is the list finite or still loading? Does focus live on each message, on a control inside it, or on the list container? Do images disclose an aspect ratio before loading? The answers decide which anchor and focus policies are valid.

State the performance target as a measurable constraint, such as “render the visible range plus a bounded buffer,” then test blank gaps and interaction latency on representative devices. Do not promise a magic maximum row count or claim a measured speedup without profiling.

Why fixed-height slicing breaks

With a fixed row height h, the first candidate row is approximately floor(scrollTop / h). Render enough rows to cover the viewport plus overscan. That formula depends on every row above a target having the same height. One 180-pixel image row makes all later index × h starts wrong.

For variable heights, maintain an estimate for unmeasured rows and replace it with an actual measurement once each row renders. An offset for item i is the sum of sizes before i; a production virtualizer uses a measurement cache and efficient range lookup instead of recomputing every prefix for every frame. TanStack's dynamic measurement example demonstrates an estimate plus measureElement for rendered rows.

An estimate is a provisional layout value, not truth. If a row above the viewport turns out taller, the virtualizer's total size and later starts change. This is why you need a correction policy and tests at the exact moment the measurement arrives.

Overscan helps a fast scroll avoid blank space, but it does not fix wrong offsets. Setting overscan so high that most of the list is mounted also defeats the original memory and DOM objective. Tune it with traces rather than using it to hide an incorrect calculation.

Work the anchor calculation on paper

Use these estimated row sizes in pixels. The pane currently has scrollTop = 130. The visible anchor is message C, which starts at 120, so the viewport is 10 pixels inside C.

Message IDOld heightOld startMeasured height
A60080
B606050
C6012060
D6018060

After measuring, C starts at 80 + 50 = 130. To keep the same point in C at the top of the pane, set scrollTop to 130 + 10 = 140. Updating only the row positions while leaving scrollTop = 130 moves the content the user was reading by 10 pixels.

Now prepend two older rows with measured heights 50 and 80. C starts at 50 + 80 + 80 + 50 = 260; the corrected scroll offset is 260 + 10 = 270. The new rows do not have to be visible to affect the result.

The invariant is: the same stable item ID and the same intra-item pixel offset remain at the viewport anchor. If the anchor row itself is deleted, choose an explicit fallback, such as the next surviving row, and test it. A raw scroll offset cannot encode that decision.

The strip heights in the diagram are schematic; the labels and offsets carry the pixel arithmetic.

Build a small correctness oracle

This pure JavaScript function is deliberately simple enough to dry run. It models a settled layout snapshot, not the full asynchronous virtualizer. The input arrays are ordered rows with stable IDs and current heights.

function startOf(rows, id) {
  let offset = 0;
  for (const row of rows) {
    if (row.id === id) return offset;
    offset += row.height;
  }
  throw new Error(`Missing anchor: ${id}`);
}

function preserveAnchor(oldRows, newRows, anchorId, oldScrollTop) {
  const within = oldScrollTop - startOf(oldRows, anchorId);
  return Math.max(0, startOf(newRows, anchorId) + within);
}

const before = [
  { id: 'A', height: 60 }, { id: 'B', height: 60 },
  { id: 'C', height: 60 }, { id: 'D', height: 60 },
];
const measured = [
  { id: 'A', height: 80 }, { id: 'B', height: 50 },
  { id: 'C', height: 60 }, { id: 'D', height: 60 },
];
const prepended = [
  { id: 'X', height: 50 }, { id: 'Y', height: 80 }, ...measured,
];

console.assert(preserveAnchor(before, measured, 'C', 130) === 140);
console.assert(preserveAnchor(measured, prepended, 'C', 140) === 270);

The first assertion checks a net 10-pixel change above C; the second checks a 130-pixel prepend. Add shrinking rows, a reordered list, an anchor at the start of a row, and a missing anchor. A missing ID throws here so a caller must choose a fallback instead of silently anchoring to the wrong item.

In the UI, capture the anchor ID and its viewport-relative offset before data mutation. After layout and measurement, find that ID in the new data and apply the difference. Coordinate this with the virtualizer's own correction. Some virtualizers already adjust for an above-viewport measurement; applying your own delta as well creates a second jump.

Connect the oracle to a real virtualizer

The important library hooks are stable keys, estimates, measurement and range selection. In TanStack Virtual, getItemKey should use each message's stable ID instead of the default index when the collection can change. Rendered rows can call virtualizer.measureElement through a ref and provide their index for measurement lookup. The API also exposes shouldAdjustScrollPositionOnItemSizeChange for fine control over changes above the viewport.

Use the library's correction behavior first, then add only the policy it does not provide for your product. In a chat, for example, a user at the bottom may want to remain pinned to the latest message, while someone reading history should remain anchored to the first visible historical message. That is a product decision, not a universal virtualizer default.

For route navigation, store enough information to find the same item again. TanStack documents a measured-item snapshot with the current scroll offset for remounting; unmeasured items still depend on estimates. If messages can be inserted between visits, restoring by stable item ID plus intra-item position can be more robust than replaying a stale pixel number alone.

Browser scroll anchoring may also adjust the page after DOM changes. MDN's overflow-anchor reference explains how to opt out on a container. Decide which layer owns the correction and test in supported browsers; disabling the browser feature without a verified replacement can introduce jumps.

Keep focus when rows unmount

Virtualization removes off-screen DOM. If the focused button is inside a row that gets unmounted, the browser cannot retain focus on that element. An index-only “focused row” state is also unstable after a prepend or reorder.

Store a stable focused item ID. Keep its row mounted while focus remains inside it, or deliberately move focus to a persistent list-level control before unmounting and restore it when the row returns. If you render a roving tab stop, only the intended item should enter the tab order. Never leave an invisible duplicate button tabbable in an overscan area.

Tell the interviewer what the keyboard contract is: Tab enters the list or its controls, arrows or documented feed commands move between items where appropriate, and Enter activates the focused control. Test that pressing Page Down, loading older rows, or filtering does not strand focus in body.

The WAI-ARIA feed pattern is useful when the interface is actually a stream of articles that loads and removes content while assistive technology reads it. It describes a focus and loading agreement and suggested keyboard actions. It is not a command to turn every virtualized list into role="feed". For a simple list, start with native list semantics and verify what the screen reader announces as items are added or removed.

When only part of a set is in the DOM, communicate position and set size when the chosen semantics support it. If total count is unknown, do not announce a fabricated total. Test a real assistive technology and browser pairing; static ARIA markup inspection alone cannot prove that the reading cursor and focus follow the intended row.

Test the failure modes the interviewer will probe

A useful test matrix varies data mutation, measurement timing, and input method. For data, try append, prepend, delete and reorder. For measurement, try a late image, a font change and a width change that wraps text. For input, use wheel, touch, keyboard and screen reader navigation.

One adversarial sequence is: focus a control in message C; scroll far enough that normal overscan would evict C; prepend two messages; then let a previously estimated image above C expand. The assertions are that focus still has a deliberate owner, C stays at the chosen visual offset when appropriate, there is no blank gap, and a single measurement causes a single correction.

Another sequence is rapid scrolling during an image load. Capture scrollTop, the top visible ID and its bounding rectangle before and after measurement. A low render count is not enough; compare which content the reader actually sees. Measure main-thread work and jank separately from correctness.

For a coding interview, implement the pure offset calculation first. Explain how you would replace the O(n) prefix loop with a data structure or virtualizer API for a large collection. Then state what the component integration still needs: scroll ownership, focus retention, browser testing and accessibility review.

Practice related frontend questions

QuestionPractice focus
Selection-State Class for an Infinite-Scroll Checkbox List with Select AllIdentity across pages
Design a reusable React comment componentGrowing feed
Debug and optimize React performance issuesLayout diagnosis
Load and Display a React List with useEffectStable row keys
Design a Performant Frontend News FeedScroll restoration

Practice the frontend news feed design prompt with the C anchor example. Show the interviewer the exact 130 → 140 → 270 transition, then explain how you would keep focus and verify the browser behavior.

Sources and Further Reading