ðŸĶ–DinosaurMachine CodingPerformanceIntersection Observer

Build Infinite Scroll

Infinite scroll tests Intersection Observer, pagination state, loading states, scroll restoration, and performance with large lists. A staple machine coding question.

Build Infinite Scroll

Interview Question: "Implement infinite scroll that loads more items as the user scrolls to the bottom."

Requirements

  • Load initial page of data
  • Detect when user nears the bottom (Intersection Observer)
  • Fetch and append next page
  • Show loading indicator
  • Handle errors with retry
  • Prevent duplicate fetches
  • Optional: scroll restoration, virtualization for large lists

Core Hook

function useInfiniteScroll<T>({
  fetchPage,
  pageSize = 20,
}: {
  fetchPage: (page: number, signal: AbortSignal) => Promise<T[]>;
  pageSize?: number;
}) {
  const [items, setItems] = useState<T[]>([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const loadingRef = useRef(false);
 
  const loadMore = useCallback(async () => {
    if (loadingRef.current || !hasMore) return;
    loadingRef.current = true;
    setLoading(true);
    setError(null);
 
    const controller = new AbortController();
    try {
      const newItems = await fetchPage(page, controller.signal);
      setItems(prev => [...prev, ...newItems]);
      setHasMore(newItems.length >= pageSize);
      setPage(prev => prev + 1);
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        setError(err as Error);
      }
    } finally {
      setLoading(false);
      loadingRef.current = false;
    }
  }, [page, hasMore, fetchPage, pageSize]);
 
  return { items, loading, hasMore, error, loadMore };
}

Sentinel Element with Intersection Observer

function InfiniteList<T>({
  fetchPage,
  renderItem,
  getKey,
}: {
  fetchPage: (page: number, signal: AbortSignal) => Promise<T[]>;
  renderItem: (item: T) => ReactNode;
  getKey: (item: T) => string;
}) {
  const { items, loading, hasMore, error, loadMore } = useInfiniteScroll({ fetchPage });
  const sentinelRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    loadMore();
  }, []);
 
  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;
 
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) loadMore();
      },
      { rootMargin: '200px' }
    );
 
    observer.observe(sentinel);
    return () => observer.disconnect();
  }, [loadMore]);
 
  return (
    <div className="infinite-list">
      {items.map(item => (
        <div key={getKey(item)} className="list-item">
          {renderItem(item)}
        </div>
      ))}
 
      {loading && <div className="loader">Loading...</div>}
 
      {error && (
        <div className="error">
          <p>Failed to load. </p>
          <button onClick={loadMore}>Retry</button>
        </div>
      )}
 
      {hasMore && !loading && !error && (
        <div ref={sentinelRef} className="sentinel" aria-hidden="true" />
      )}
 
      {!hasMore && <div className="end-message">No more items</div>}
    </div>
  );
}

Why Intersection Observer Over Scroll Events

ApproachProsCons
scroll eventWorks everywhereFires on every pixel, needs throttle, causes layout thrashing with getBoundingClientRect
Intersection ObserverAsync, no main-thread blocking, configurable marginsSlightly more setup
// BAD: scroll-based (causes jank)
window.addEventListener('scroll', throttle(() => {
  const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
  if (scrollTop + clientHeight >= scrollHeight - 200) loadMore();
}, 100));
 
// GOOD: Intersection Observer (zero layout thrashing)
const observer = new IntersectionObserver(callback, { rootMargin: '200px' });
observer.observe(sentinelElement);

Virtualization for Large Lists

When the list exceeds ~1000 items, DOM node count becomes a problem. Combine infinite scroll with windowing:

function VirtualizedInfiniteList({ items, loadMore, hasMore, itemHeight, containerHeight }) {
  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);
 
  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={e => {
        const target = e.currentTarget;
        setScrollTop(target.scrollTop);
        if (target.scrollTop + target.clientHeight >= target.scrollHeight - 200) {
          loadMore();
        }
      }}
    >
      <div style={{ height: items.length * itemHeight, position: 'relative' }}>
        {visibleItems.map((item, i) => (
          <div
            key={item.id}
            style={{
              position: 'absolute',
              top: (startIndex + i) * itemHeight,
              height: itemHeight,
              width: '100%',
            }}
          >
            {renderItem(item)}
          </div>
        ))}
      </div>
    </div>
  );
}

What Interviewers Look For

  1. Intersection Observer — Not scroll events, understands rootMargin for preloading
  2. Race condition prevention — loadingRef flag prevents duplicate fetches
  3. Error handling — Retry mechanism, AbortController on unmount
  4. End-of-list detection — hasMore flag based on page size comparison
  5. Virtualization awareness — Mentions windowing for 1000+ item lists, knows react-window / @tanstack/virtual