跪拜 Guibai
← Back to the summary

AI-Generated Virtual Lists White-Screen Under Fast Scroll — Here's the Fix

Last week in an interview, the interviewer threw out a question:

"The page needs to render 100,000 rows of data without pagination. How would you do it?"

I said virtual list. He nodded, then said: "Write one by hand."

I thought, how hard could this be? I opened the editor, had AI generate a version — it ran, and it looked fine. The interviewer took it, scrolled quickly a few times, and the page flashed white.

He put down the mouse and said: "Do you know why it went white?"

I couldn't answer. I went back and wrote a version by hand, and only then understood exactly where the AI version fell short.

First, look at the AI-generated version — it runs, but has a trap

The code AI gave looked roughly like this:

function VirtualList({ items, itemHeight = 36, containerHeight = 500 }) {
  const [scrollTop, setScrollTop] = useState(0);

  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.min(
    startIndex + Math.ceil(containerHeight / itemHeight) + 1,
    items.length
  );

  const visibleItems = items.slice(startIndex, endIndex);
  const totalHeight = items.length * itemHeight;
  const offsetY = startIndex * itemHeight;

  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {visibleItems.map((item, i) => (
            <div key={startIndex + i} style={{ height: itemHeight }}>
              Row {items[startIndex + i]}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

Logically it's correct: calculate the start and end indices of the visible area, render only those few dozen DOM nodes, and use transform to offset them into the correct position.

Scrolling slowly is fine. But drag the scrollbar fast — white screen.

Why? Because onScroll is asynchronous. When you scroll fast, the browser has already moved the viewport to the new position, but React hasn't had time to re-render those few dozen nodes. In those intermediate frames, the old nodes have been scrolled away and the new ones haven't mounted yet — white screen.

Handwritten version: just add a buffer zone

The core idea to fix the white screen is overscan — render a few extra rows above and below the visible area so there's "inventory" to display during fast scrolling.

function VirtualList({ items, itemHeight = 36, containerHeight = 500 }) {
  const [scrollTop, setScrollTop] = useState(0);
  const overscan = 5;

  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
  const endIndex = Math.min(
    Math.floor(scrollTop / itemHeight) + visibleCount + overscan,
    items.length
  );

  const visibleItems = items.slice(startIndex, endIndex);
  const totalHeight = items.length * itemHeight;
  const offsetY = startIndex * itemHeight;

  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {visibleItems.map((_, i) => (
            <div key={startIndex + i} style={{ height: itemHeight }}>
              Row {startIndex + i + 1}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

The core change is just 3 lines:

+ const overscan = 5;
- const startIndex = Math.floor(scrollTop / itemHeight);
+ const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
- const endIndex = startIndex + Math.ceil(containerHeight / itemHeight) + 1;
+ const endIndex = Math.floor(scrollTop / itemHeight) + visibleCount + overscan;

Render 5 extra rows above and below. During fast scrolling, these 5 rows act as a buffer — they're already in the DOM when the browser scrolls past, so no white screen.

The interviewer's 6 follow-up questions

After explaining the white screen issue, the interviewer followed up with 6 more questions. Each one is an advanced interview topic.

Follow-up 1: Why use transform instead of padding-top?

// ❌ padding-top approach
<div style={{ paddingTop: offsetY }}>

// ✅ transform approach
<div style={{ transform: `translateY(${offsetY}px)` }}>

Difference: paddingTop triggers a layout reflow, forcing the browser to recalculate all element positions. transform only triggers compositing — the GPU moves the layer directly, bypassing Layout and Paint.

When scrolling frantically through 100,000 rows, this difference can reach 5-10x frame rate disparity.

Follow-up 2: What if each row has a different height?

This is the hardest part of virtual lists. Fixed height lets you directly calculate scrollTop / itemHeight; variable heights break that.

Core idea: estimate height + correct after rendering.

function useDynamicVirtualList(items, estimatedHeight = 50) {
  const [scrollTop, setScrollTop] = useState(0);
  const measuredHeights = useRef(new Map());

  const getItemOffset = (index) => {
    let offset = 0;
    for (let i = 0; i < index; i++) {
      offset += measuredHeights.current.get(i) ?? estimatedHeight;
    }
    return offset;
  };

  const findStartIndex = () => {
    let offset = 0;
    for (let i = 0; i < items.length; i++) {
      const h = measuredHeights.current.get(i) ?? estimatedHeight;
      if (offset + h > scrollTop) return Math.max(0, i - 3);
      offset += h;
    }
    return 0;
  };

  const measureRef = useCallback((index, el) => {
    if (el) {
      const height = el.getBoundingClientRect().height;
      if (measuredHeights.current.get(index) !== height) {
        measuredHeights.current.set(index, height);
      }
    }
  }, []);

  return { findStartIndex, getItemOffset, measureRef, setScrollTop };
}

First use estimatedHeight to guess the height of all unrendered rows, then after rendering use getBoundingClientRect to measure the real height and store it in a Map. Next time you calculate offsets, prefer the real height.

In an interview, you just need to explain this idea clearly — no need to write the full implementation.

Follow-up 3: Doesn't onScroll fire too often?

Yes. During fast scrolling it can fire 60+ times per second, each time calling setState → re-render.

Optimization: throttle with requestAnimationFrame.

const rafRef = useRef(null);

const handleScroll = (e) => {
  const scrollTop = e.currentTarget.scrollTop;
  if (rafRef.current) cancelAnimationFrame(rafRef.current);
  rafRef.current = requestAnimationFrame(() => {
    setScrollTop(scrollTop);
  });
};

Why requestAnimationFrame instead of throttle? Because requestAnimationFrame automatically aligns with the browser's refresh frames — it updates only once per frame, perfectly synchronized with the rendering rhythm. Throttling with setTimeout can desync from the frame rate, causing tearing or jank.

Follow-up 4: What should the key be?

// ❌ using index
{visibleItems.map((item, i) => (
  <div key={i}>{item}</div>
))}

// ✅ using the data's real ID or absolute index
{visibleItems.map((item, i) => (
  <div key={startIndex + i}>{item}</div>
))}

Virtual list key is different from regular lists. Regular lists use array index because the elements don't change. In a virtual list, the same key=0 is row 1's data before scrolling, and row 50's data after scrolling — React will reuse the DOM but not update the content, causing display chaos.

You must use the data's unique identifier or absolute position index as the key.

Follow-up 5: Virtual list vs. time slicing — which is better?

Dimension Virtual List Time Slicing
Principle Only render visible area Render all DOM in batches
DOM count Always low (~20-30) Eventually all rendered
Memory Low High (100k DOM nodes all in memory)
First paint speed Fast Fast (first batch renders immediately)
Scroll experience Needs white-screen handling Native scrolling, silky smooth
Use case 100k+ rows 1000-5000 rows

Use virtual list for datasets over 5,000 rows; time slicing works for under 5,000. Time slicing eventually mounts all DOM nodes — 100,000 DOM nodes on a page can eat 200MB+ of memory alone.

Follow-up 6: Mature libraries exist, so why write it by hand?

The interviewer threw this one at the end.

Honestly, in production you really shouldn't hand-write it — use @tanstack/react-virtual (TanStack Virtual). It handles dynamic heights, horizontal scrolling, infinite loading, and other edge cases. Hand-rolling takes hundreds of lines; the library packages it all up.

But you need to know what it's doing. When it has a bug — say the scroll jumps at a certain position, or the scroll position shifts after dynamically loading new data — without understanding the principles, you're just guessing at a black box.

That's why interviews still test this.

Virtual list interview cheat sheet

Topic Key Points
Core principle Only render elements in the visible area; calculate start/end indices from scrollTop
White screen cause Scrolling is synchronous, rendering is asynchronous; no buffer zone causes white screen
Buffer (overscan) Render N extra rows above and below, typically 5-10 rows
Positioning method transform: translateY() is better than paddingTop (avoids reflow)
Variable height handling Estimate height + correct with getBoundingClientRect after rendering
Scroll throttling requestAnimationFrame is better than setTimeout/throttle
Key selection Use data's unique ID or absolute index; never use loop index
vs. Time slicing >5,000 rows use virtual list; <5,000 rows can use time slicing
Production solution @tanstack/react-virtual (formerly react-virtual)

Is handwriting skill still useful in the AI era?

The interviewer said one last thing that I think makes a lot of sense:

"AI can generate a virtual list, but the version it generated went white. Knowing why it went white and how to fix it — that's your value."

AI writes code better and better; anyone can generate code that runs. But knowing why it breaks in certain scenarios requires understanding the principles. Interviews test handwriting not to see if you can write it — but to see if you know how to fix it when it breaks.

Have you been asked to hand-write a virtual list in an interview? Did you pull it off?