Rendering ten thousand list rows into the DOM at once is the single fastest way to make a page unresponsive — windowing solves this by keeping only a thin slice of rows mounted at any time, and driving that slice with IntersectionObserver sentinels avoids the constant scroll-event arithmetic that older virtualization libraries depend on.

Concept Framing

A virtualized or "windowed" list renders a small, constantly shifting subset of a much larger data set — enough rows to fill the viewport plus a small buffer, and nothing more. The technique matters because DOM nodes are expensive: each mounted row carries its own style recalculation cost, its own layout box, and its own event listeners. A 50,000-row list rendered in full can hold the main thread hostage during the very first paint, long before a user scrolls anywhere.

The classic implementation, used by libraries like react-window, listens to the scroll event, reads scrollTop on every firing, and does arithmetic against a known (or estimated) row height to compute which index range should be mounted. This works, but it ties the recompute cost to the frequency of scroll events, which can fire dozens of times per second during a fling.

An observer-driven approach flips the trigger: instead of asking "where is the user right now?" on every scroll tick, it asks "has a sentinel element crossed the edge of the rendered window?" A sentinel is an otherwise-empty element placed at the top and bottom edge of the currently mounted range. A single IntersectionObserver watches both sentinels with a rootMargin that extends the trigger zone outward — the overscan buffer. When a sentinel crosses that expanded boundary, the callback recomputes the range and mounts the next batch of rows before the user can see the empty gap.

This is the same sentinel pattern used for infinite scroll pagination, applied in both directions instead of only downward. The difference from a simple "load more" sentinel is that windowing also unmounts rows that scroll far enough away, keeping the mounted node count roughly constant regardless of how large the underlying data set grows.

The diagram below shows the anatomy of a windowed list: a full list far taller than the viewport, an overscan buffer of mounted-but-off-screen rows on each side, the visible viewport in the middle, and the two sentinel elements that trigger remounting.

Virtual List Windowing with Overscan Sentinels A vertical list with far more rows than fit on screen. A viewport window shows the visible rows in the middle, flanked by overscan buffer zones above and below that are still mounted, with top and bottom sentinel elements at the buffer boundaries. Arrows show the sentinels feeding an IntersectionObserver, which mounts new rows and unmounts far rows as the user scrolls. Full list (thousands of rows) unrendered rows overscan buffer (mounted) top sentinel visible viewport (rows the user sees) bottom sentinel overscan buffer (mounted) unrendered rows IntersectionObserver rootMargin = overscan size mounts/unmounts rows on crossing

Because the sentinels sit at the edges of the mounted range rather than on every row, a list of any size costs the same observer overhead: two watched elements, regardless of whether the underlying data has 500 rows or 500,000.

Why Sentinel Windowing Scales Better Than Per-Row Observation

An earlier, simpler pattern — observing every row directly and toggling a "rendered" flag as each one enters or leaves the root bounds — works fine for small and medium lists. It falls over at scale for a structural reason: the browser's observation registry grows linearly with the number of observed targets, and every scroll frame potentially delivers a batch of entries proportional to how many rows just crossed a boundary. On a fling through a 20,000-row list, that can mean hundreds of entries per callback invocation, each requiring a DOM read or write to toggle visibility.

Sentinel windowing decouples observer count from data-set size entirely. Two elements are observed no matter how large the list grows, and the callback only fires when one of those two elements crosses the boundary — an event that happens a small, bounded number of times per scroll gesture, not once per row. The cost of deciding which rows to mount moves from "one observer entry per crossed row" to "one range computation per sentinel crossing," which is a constant-time operation against the row-height cache rather than a per-row callback.

Spec / Signature Reference Table

Concept Type Role in windowing
Top sentinel HTMLElement Marks the upper edge of the mounted range; triggers mounting earlier rows when it crosses the root bounds
Bottom sentinel HTMLElement Marks the lower edge of the mounted range; triggers mounting later rows when it crosses the root bounds
rootMargin (overscan) string Expands the trigger zone around the scroll container so rows mount before they are visible, not after
Top spacer HTMLElement (height set via style) Reserves scroll height for unrendered rows above the window so the scrollbar length stays correct
Bottom spacer HTMLElement (height set via style) Reserves scroll height for unrendered rows below the window
Row-height cache Map<number, number> Per-index measured heights, used to compute spacer sizes and the visible index range for variable-height rows
ResizeObserver per row ResizeObserver Reports the actual rendered height of a row once its content has laid out, correcting the cache's initial estimate
Mounted range { start: number; end: number } The current slice of the data array that has corresponding DOM nodes

Step-by-Step Implementation

Step 1 — Structure the scroll container

The container needs three logical zones: a top spacer, the mounted rows, and a bottom spacer. The spacers are plain elements whose height is derived from the row-height cache, not from actual content.

TypeScript
interface WindowState {
  start: number; // first mounted index
  end: number;   // last mounted index (exclusive)
}

interface RowHeights {
  cache: Map<number, number>;
  estimate: number; // fallback height for unmeasured rows
}

function spacerHeight(range: [number, number], heights: RowHeights): number {
  let total = 0;
  for (let i = range[0]; i < range[1]; i++) {
    total += heights.cache.get(i) ?? heights.estimate;
  }
  return total;
}

Step 2 — Create sentinels and one shared observer

A single IntersectionObserver watches both sentinels. The rootMargin value is the overscan buffer, expressed in pixels or as a percentage of the viewport.

TypeScript
function createWindowObserver(
  onBoundaryCrossed: (which: 'top' | 'bottom') => void,
  overscanPx = 600
): { observer: IntersectionObserver; observe: (top: Element, bottom: Element) => void } {
  const targets = new WeakMap<Element, 'top' | 'bottom'>();

  const observer = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      const which = targets.get(entry.target);
      if (which) onBoundaryCrossed(which);
    }
  }, { rootMargin: `${overscanPx}px 0px ${overscanPx}px 0px`, threshold: 0 });

  return {
    observer,
    observe(top: Element, bottom: Element): void {
      targets.set(top, 'top');
      targets.set(bottom, 'bottom');
      observer.observe(top);
      observer.observe(bottom);
    },
  };
}

Step 3 — Recompute the visible range on a boundary crossing

When a sentinel intersects, translate the current scrollTop into a row index range using the height cache, then widen it by one overscan buffer's worth of rows on each side.

TypeScript
function computeRange(
  scrollTop: number,
  viewportHeight: number,
  totalRows: number,
  heights: RowHeights,
  overscanRows = 5
): WindowState {
  let offset = 0;
  let start = 0;
  while (start < totalRows && offset + (heights.cache.get(start) ?? heights.estimate) < scrollTop) {
    offset += heights.cache.get(start) ?? heights.estimate;
    start++;
  }
  let end = start;
  let visible = 0;
  while (end < totalRows && visible < viewportHeight) {
    visible += heights.cache.get(end) ?? heights.estimate;
    end++;
  }
  return {
    start: Math.max(0, start - overscanRows),
    end: Math.min(totalRows, end + overscanRows),
  };
}

Step 4 — Mount and unmount rows for the new range

Only the rows inside [start, end) get real DOM nodes. Rows leaving the range are unmounted, and their ResizeObserver entries — covered in the WeakMap-based observer registry pattern — are removed alongside them.

TypeScript
function reconcileRows(
  previous: WindowState,
  next: WindowState,
  mount: (index: number) => void,
  unmount: (index: number) => void
): void {
  for (let i = previous.start; i < previous.end; i++) {
    if (i < next.start || i >= next.end) unmount(i);
  }
  for (let i = next.start; i < next.end; i++) {
    if (i < previous.start || i >= previous.end) mount(i);
  }
}

Step 5 — Re-measure with ResizeObserver and reposition

Each mounted row is observed individually so its real rendered height replaces the estimate in the cache, and the spacer heights are recalculated from the corrected values.

TypeScript
function observeRowHeight(
  row: HTMLElement,
  index: number,
  heights: RowHeights,
  onMeasured: () => void
): () => void {
  const ro = new ResizeObserver((entries) => {
    const size = entries[0]?.borderBoxSize?.[0];
    if (!size) return;
    const measured = Math.round(size.blockSize);
    if (heights.cache.get(index) !== measured) {
      heights.cache.set(index, measured);
      onMeasured(); // triggers spacer + sentinel repositioning
    }
  });
  ro.observe(row, { box: 'border-box' });
  return () => ro.disconnect();
}

Every mount/unmount cycle repeats steps 3 through 5. The callback throttling and debouncing guide applies here if row-mounting itself becomes expensive — batching several boundary crossings into one requestAnimationFrame-scheduled reconcile prevents redundant work during a fast fling.

Configuration Variants

Variant Trigger mechanism Best for Trade-off
Fixed row height + numeric overscan Sentinel crossing, rootMargin in px Uniform rows (tables, chat logs) Simplest math; wrong if any row varies in height
Variable row height + ResizeObserver cache Sentinel crossing, cache-driven range math Cards, comments, rich content More bookkeeping; spacer height is an estimate until rows are measured
Percentage-based rootMargin overscan Sentinel crossing, rootMargin in % Lists inside resizable panes Overscan naturally scales with viewport size
Fixed scroll-offset virtualization (no observer) scroll event + arithmetic Very high-frequency scroll analytics, existing library adoption Recomputes on every scroll tick; higher steady-state main-thread cost
Per-row observation (no sentinels) One IntersectionObserver entry per row Small to medium lists (under ~500 rows) Avoids sentinel math entirely, but does not scale to very large lists

The sentinel-and-overscan pattern described in this guide sits between the two extremes: it recomputes far less often than scroll-offset virtualization, while still scaling to lists an order of magnitude larger than per-row observation can comfortably handle.

Edge Cases & Gotchas

Sentinel elements move when spacer height changes

Because a sentinel's position depends on the spacer height above it, correcting the row-height cache after a ResizeObserver measurement shifts the sentinel's absolute position. If the observer is still watching the sentinel with the old rootMargin, this can produce a spurious re-intersection. Guard the callback so it ignores crossings that do not actually change the computed range.

Overscan too small for the row-mount cost

If mounting a row is expensive — heavy images, complex nested components — a small overscan buffer will not give the browser enough lead time, and users will see blank space flash in during a fast scroll. Profile the cost of a single row mount and size the overscan so that overscanRows × mountCostPerRow comfortably exceeds the time between two scroll samples.

Nested scroll containers and the wrong root

If the list scrolls inside a container that is not the nearest scrolling ancestor of the sentinels, root must be set explicitly to that container. Leaving root: null (the browser viewport) when the actual scrolling happens inside a nested overflow: auto panel means the sentinels never cross the intended boundary — the trigger zone is measured against the wrong element entirely.

Spacer height drift before all rows are measured

Until a row has been mounted at least once, its height is only an estimate. Scrolling quickly to a distant, never-measured section of the list can produce a visibly jumpy scrollbar as more accurate heights replace the estimate. Seeding the estimate from an average of already-measured rows, rather than a single hardcoded constant, reduces this drift considerably.

Re-observing after remount

Sentinels and rows are real DOM nodes that get destroyed and recreated as the window shifts. Every remount must re-observe() the new element instances — an observer's registration does not survive across a node being removed and a new node being inserted in its place, even if both represent "the top sentinel" conceptually.

Scroll anchoring fights with spacer resizing

Chromium's scroll-anchoring feature tries to preserve the visual position of content when the DOM changes above the viewport. When a spacer's height is corrected after a late ResizeObserver measurement, scroll anchoring may compensate by silently adjusting scrollTop to keep the currently visible row in place — which then triggers another range recomputation, which can trigger another spacer correction. Break the loop by setting overflow-anchor: none on the spacer elements themselves, so browser-side anchoring never treats their resize as something worth compensating for.

Keyboard focus crossing the window boundary

If a focusable row (a link, a button, an input) is unmounted while it holds keyboard focus — because the user tabbed into a row that then scrolled out of the overscan range — focus silently reverts to the document body. Guard against this by checking document.activeElement before unmounting a row; if the row about to leave the mounted range contains the active element, either skip that unmount for one cycle or move focus to a sensible neighbor first.

Framework Integration Patterns

React

TypeScript
import { useEffect, useRef, useState } from 'react';

function useWindowedList(totalRows: number, overscanPx = 600) {
  const [range, setRange] = useState<WindowState>({ start: 0, end: 20 });
  const topSentinel = useRef<HTMLDivElement>(null);
  const bottomSentinel = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!topSentinel.current || !bottomSentinel.current) return;
    const { observer, observe } = createWindowObserver((which) => {
      // recompute range using scrollTop of the nearest scroll container
      setRange((prev) => recomputeAfterCrossing(prev, which, totalRows));
    }, overscanPx);
    observe(topSentinel.current, bottomSentinel.current);
    return () => observer.disconnect(); // teardown on unmount or totalRows change
  }, [totalRows, overscanPx]);

  return { range, topSentinel, bottomSentinel };
}

Vue 3

TypeScript
import { ref, onMounted, onUnmounted } from 'vue';

export function useWindowedList(totalRows: number, overscanPx = 600) {
  const range = ref<WindowState>({ start: 0, end: 20 });
  const topSentinel = ref<HTMLElement | null>(null);
  const bottomSentinel = ref<HTMLElement | null>(null);
  let observer: IntersectionObserver | null = null;

  onMounted(() => {
    if (!topSentinel.value || !bottomSentinel.value) return;
    const created = createWindowObserver((which) => {
      range.value = recomputeAfterCrossing(range.value, which, totalRows);
    }, overscanPx);
    observer = created.observer;
    created.observe(topSentinel.value, bottomSentinel.value);
  });

  onUnmounted(() => observer?.disconnect());

  return { range, topSentinel, bottomSentinel };
}

Angular

TypeScript
import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core';

@Directive({ selector: '[appWindowSentinel]', standalone: true })
export class WindowSentinelDirective implements OnInit, OnDestroy {
  @Input('appWindowSentinel') position!: 'top' | 'bottom';
  private observer?: IntersectionObserver;

  constructor(private el: ElementRef<HTMLElement>) {}

  ngOnInit(): void {
    this.observer = new IntersectionObserver(
      ([entry]) => { if (entry.isIntersecting) this.handleCrossing(this.position); },
      { rootMargin: '600px 0px 600px 0px', threshold: 0 }
    );
    this.observer.observe(this.el.nativeElement);
  }

  private handleCrossing(which: 'top' | 'bottom'): void {
    // delegate to a shared windowing service
  }

  ngOnDestroy(): void { this.observer?.disconnect(); }
}

In every framework, the observer must be recreated (or re-observe()d) whenever the sentinel elements are unmounted and remounted — for instance, when totalRows changes and the list re-renders from scratch. Treat sentinel lifetime the same way you treat any other observer lifecycle concern: always pair an observe() call with a matching teardown path.

SSR and First Paint

Server-rendered pages have no IntersectionObserver, and a windowed list rendered without any client-side hydration has no way to know which rows should be visible. The safe pattern is to server-render a fixed first window — the first N rows sized to fill a typical viewport — with real content, and only attach the sentinel-driven observer once the client has hydrated:

TypeScript
function initialServerRange(estimatedViewportRows = 20): WindowState {
  // Rendered identically on server and client for the first paint —
  // avoids a hydration mismatch before the observer takes over.
  return { start: 0, end: estimatedViewportRows };
}

function createClientObserverIfSupported(
  onBoundaryCrossed: (which: 'top' | 'bottom') => void,
  overscanPx: number
): ReturnType<typeof createWindowObserver> | null {
  if (typeof window === 'undefined' || !('IntersectionObserver' in window)) return null;
  return createWindowObserver(onBoundaryCrossed, overscanPx);
}

Without this guard, a windowed list built entirely client-side either renders nothing until hydration completes (a blank flash) or throws a ReferenceError during server rendering. The same typeof window === 'undefined' guard used across every observer-based pattern on this site applies here without modification.

Performance Expectations

The table below summarizes the practical effect of moving from a fully rendered list to sentinel-driven windowing, measured across the row counts where each approach is commonly chosen.

Row count Fully rendered DOM nodes Windowed DOM nodes (typical overscan) First paint impact
200 200 200 (windowing usually skipped) Negligible either way
2,000 2,000 30–50 Fully rendered list adds noticeable style/layout cost on initial mount
20,000 20,000 30–50 Fully rendered list can exceed a 1-second main-thread block on mid-range devices
200,000 200,000 (rarely attempted) 30–50 Fully rendered approach is impractical; windowing cost stays flat

The mounted node count for a windowed list stays roughly constant regardless of total row count, because it is a function of viewport height and overscan size — not of how much data exists. This is the core argument for adopting windowing once a list regularly exceeds a few thousand rows, rather than waiting until performance complaints force the change.

Debugging Checklist

  • root
  • rootMargin
  • Sentinels re-observe()
  • Row-height cache seeded with a reasonable estimate, not 0
  • ResizeObserver
  • Unmounted rows also unobserved (ResizeObserver.unobserve or disconnect()

FAQ

How many rows should the overscan buffer render outside the visible viewport?

Start with an overscan equal to one to two viewport heights in each direction, expressed as a rootMargin percentage or pixel value. This gives the browser enough lead time to mount new rows before they scroll into view. Increase the buffer for slower row-mount code or fling-heavy scroll behavior, and decrease it for very tall rows where a full extra viewport of overscan would mount far more nodes than necessary.

Can I use IntersectionObserver windowing with variable-height rows?

Yes, but you need a row-height cache populated by ResizeObserver rather than a fixed row height constant. The spacer elements above and below the window use the sum of cached heights (with an estimate for unmeasured rows) to keep the scrollbar's total scroll height stable as the user scrolls through unmeasured territory.

Why do sentinels sometimes get skipped during a fast fling scroll?

IntersectionObserver reports geometry at the browser's own sampling rate, and during a fast fling the sentinel can cross the entire root bounds between two samples, producing no intersection callback at all. A generous rootMargin overscan compensates by giving the sentinel a wider trigger zone, so it is far less likely to be skipped entirely between samples.

Is rootMargin-based windowing faster than scroll-offset virtualization?

Neither approach is universally faster; they trade off different costs. Scroll-offset virtualization recalculates the visible range on every scroll event using arithmetic, which is cheap but runs constantly. IntersectionObserver windowing only recalculates when a sentinel crosses the root bounds, which fires far less often, but it depends on accurate row-height accounting to place sentinels correctly.

Do I still need spacer elements above and below the window?

Yes. Spacer elements sized to the estimated height of the unrendered rows are what let the scrollbar reflect the true length of the full list even though only a small window of rows is actually mounted. Without spacers, the scroll container would only be as tall as the mounted rows, and the scrollbar thumb size and position would be wrong.


↑ Back to Performance Optimization & Memory Management for Observer APIs