This is the full build referenced in Virtual Lists & Windowing with Observers — a working windowed list with top and bottom sentinels, rootMargin overscan, and spacer elements, written end to end in TypeScript.
Problem / Scenario Context
A product data table needs to display 15,000 rows fetched from an API, sorted and filtered client-side. Rendering all 15,000 rows produces a page that takes over a second to become interactive on a mid-range laptop, and scrolling stutters visibly because every scroll frame competes with style recalculation across thousands of live nodes. The fix is to mount only the rows near the visible area and let two sentinel elements — one above the mounted range, one below — signal when to shift that range as the user scrolls.
This build assumes fixed-height rows for simplicity; the companion guide on recycling DOM nodes in observer-driven lists extends it with a pooling strategy that avoids creating and destroying row elements on every scroll.
The fixed-height assumption is not a limitation unique to this pattern — it is the same starting point used by most production virtualization libraries, because it turns the range calculation into constant-time arithmetic instead of a running sum over a height cache. Once the mechanics below are working correctly for fixed rows, extending them to variable heights is a matter of swapping the arithmetic in computeFixedRange for a ResizeObserver-backed cache lookup — the sentinel, observer, and reconcile structure described in the sections below stays identical either way.
Mechanics Explanation
The container's DOM structure has three regions, in order: a top spacer whose height represents all unrendered rows above the window, the rows currently mounted, and a bottom spacer representing everything below. The sentinels sit at the boundary between the spacer and the mounted rows on each side. A single shared IntersectionObserver, configured with the scroll container as root and a generous rootMargin, watches both sentinels. When either one enters the expanded root bounds, the callback recalculates which row indices should be visible and adjusts the mounted range, the spacer heights, and the sentinel positions accordingly.
The root option matters more than it might first appear. If it is left as the default (null, meaning the browser viewport), the intersection calculation happens against the wrong rectangle whenever the list scrolls inside its own overflow-y: auto region rather than the whole page. Setting root explicitly to the same element used for scrollTop reads keeps the sentinel geometry and the range arithmetic in agreement — a mismatch here is one of the most common reasons a windowed list "freezes" and stops mounting new rows during a scroll.
The rootMargin value doubles as the overscan buffer for the entire list. Because it is expressed once, on the shared observer, rather than per row, changing the buffer size is a single-line change (config.rowHeight * config.overscanRows) rather than a per-element recalculation.
The top and bottom sentinels used here are the same idea behind a one-directional infinite scroll and pagination sentinel — the difference is that a windowed list also removes rows on the trailing edge instead of only ever appending on the leading edge.
Step 1 — Markup and Container Setup
interface VirtualListConfig {
totalRows: number;
rowHeight: number; // fixed height in px for this build
overscanRows: number;
container: HTMLElement; // the scrollable element
}
function buildListSkeleton(container: HTMLElement): {
topSpacer: HTMLDivElement;
topSentinel: HTMLDivElement;
rowsRegion: HTMLDivElement;
bottomSentinel: HTMLDivElement;
bottomSpacer: HTMLDivElement;
} {
container.style.overflowY = 'auto';
container.style.position = 'relative';
const topSpacer = document.createElement('div');
const topSentinel = document.createElement('div');
const rowsRegion = document.createElement('div');
const bottomSentinel = document.createElement('div');
const bottomSpacer = document.createElement('div');
topSentinel.style.height = '1px';
bottomSentinel.style.height = '1px';
// Scroll anchoring should not compensate for spacer resizing.
topSpacer.style.overflowAnchor = 'none';
bottomSpacer.style.overflowAnchor = 'none';
container.append(topSpacer, topSentinel, rowsRegion, bottomSentinel, bottomSpacer);
return { topSpacer, topSentinel, rowsRegion, bottomSentinel, bottomSpacer };
}
Step 2 — Range Calculation for Fixed-Height Rows
With a fixed row height, computing the visible index range from scrollTop is simple arithmetic rather than a running sum over the height cache.
interface Range { start: number; end: number }
function computeFixedRange(
scrollTop: number,
viewportHeight: number,
config: VirtualListConfig
): Range {
const firstVisible = Math.floor(scrollTop / config.rowHeight);
const visibleCount = Math.ceil(viewportHeight / config.rowHeight);
const start = Math.max(0, firstVisible - config.overscanRows);
const end = Math.min(config.totalRows, firstVisible + visibleCount + config.overscanRows);
return { start, end };
}
Step 3 — The Observer and the Reconcile Loop
type RowFactory = (index: number) => HTMLElement;
class WindowedList {
private range: Range = { start: 0, end: 0 };
private mounted = new Map<number, HTMLElement>();
private observer: IntersectionObserver;
private elements: ReturnType<typeof buildListSkeleton>;
constructor(
private config: VirtualListConfig,
private renderRow: RowFactory
) {
this.elements = buildListSkeleton(config.container);
this.observer = new IntersectionObserver(
(entries) => this.handleCrossing(entries),
{
root: config.container,
rootMargin: `${config.rowHeight * config.overscanRows}px 0px`,
threshold: 0,
}
);
this.observer.observe(this.elements.topSentinel);
this.observer.observe(this.elements.bottomSentinel);
this.reconcile(computeFixedRange(0, config.container.clientHeight, config));
}
private handleCrossing(entries: IntersectionObserverEntry[]): void {
const crossed = entries.some((e) => e.isIntersecting);
if (!crossed) return;
const next = computeFixedRange(
this.config.container.scrollTop,
this.config.container.clientHeight,
this.config
);
this.reconcile(next);
}
private reconcile(next: Range): void {
for (const [index, el] of this.mounted) {
if (index < next.start || index >= next.end) {
el.remove();
this.mounted.delete(index);
}
}
for (let i = next.start; i < next.end; i++) {
if (!this.mounted.has(i)) {
const el = this.renderRow(i);
el.style.position = 'absolute';
el.style.top = `${i * this.config.rowHeight}px`;
el.style.height = `${this.config.rowHeight}px`;
this.elements.rowsRegion.appendChild(el);
this.mounted.set(i, el);
}
}
this.range = next;
this.resizeSpacers(next);
}
private resizeSpacers(range: Range): void {
this.elements.topSpacer.style.height = `${range.start * this.config.rowHeight}px`;
this.elements.bottomSpacer.style.height =
`${(this.config.totalRows - range.end) * this.config.rowHeight}px`;
}
disconnect(): void {
this.observer.disconnect();
this.mounted.clear();
}
}
Using position: absolute with a top offset computed from the fixed row height means the rows region itself does not need to grow or shrink as rows mount and unmount — only the spacers change size, which is what keeps the sentinels correctly positioned relative to the mounted window.
Step 4 — Wiring It Up
const container = document.querySelector<HTMLElement>('#product-table')!;
container.style.height = '600px'; // fixed viewport height for the scroll region
const list = new WindowedList(
{ totalRows: 15000, rowHeight: 44, overscanRows: 8, container },
(index) => {
const row = document.createElement('div');
row.textContent = `Row ${index}`;
row.dataset.index = String(index);
return row;
}
);
// Teardown when the table is removed from the page (e.g. route change, tab close)
window.addEventListener('beforeunload', () => list.disconnect());
// Plain JS equivalent — remove type annotations and interfaces,
// keep the class body and method logic identical to the TypeScript version above.
Step 5 — Handling Container Resize
Sentinel crossings alone do not account for the viewport itself changing size — a browser window resize, a sidebar collapsing, or a mobile keyboard opening can all change how many rows fit on screen without moving scrollTop at all. Attach a ResizeObserver to the scroll container so the range is recomputed whenever its clientHeight changes:
class WindowedList {
// ...existing fields from Step 3...
private resizeObserver: ResizeObserver;
private attachResizeHandling(): void {
this.resizeObserver = new ResizeObserver(() => {
const next = computeFixedRange(
this.config.container.scrollTop,
this.config.container.clientHeight,
this.config
);
this.reconcile(next);
});
this.resizeObserver.observe(this.config.container);
}
disconnect(): void {
this.observer.disconnect();
this.resizeObserver.disconnect(); // pair every observe() with a teardown call
this.mounted.clear();
}
}
Call attachResizeHandling() once from the constructor, right after the sentinels are wired up. Without it, growing the viewport (for example, maximizing a browser window) can leave a visible band of unmounted spacer space at the bottom of the list until the user scrolls and triggers a sentinel crossing. If reconciling on every resize tick becomes measurably expensive on a very wide table, apply the same callback throttling and debouncing techniques used for scroll and resize handlers elsewhere on this site.
Integrating with a Changing Data Source
Real tables rarely have a static totalRows. When new data arrives — a filter changes, a websocket pushes an update, a page of results is appended — update config.totalRows and force a reconcile rather than waiting for a scroll event that may never come:
function updateTotalRows(list: WindowedList, config: VirtualListConfig, newTotal: number): void {
config.totalRows = newTotal;
// Recompute immediately; do not wait for the next sentinel crossing,
// since a shrinking data set can leave the current range out of bounds.
const next = computeFixedRange(
config.container.scrollTop,
config.container.clientHeight,
config
);
(list as unknown as { reconcile: (r: Range) => void }).reconcile(next);
}
In a production build, expose a public refresh() method on WindowedList instead of reaching past its private members — the cast above exists only to illustrate the call, not as a pattern to ship.
Verification Steps
- Load the page with the network throttled to simulate a slow API, and confirm the table becomes interactive well before all 15,000 rows would have finished mounting in a naive implementation.
- Open DevTools → Performance, record a fast fling scroll from top to bottom, and confirm the "Recalculate Style" and "Layout" entries stay small and bounded rather than growing with scroll distance.
- Resize the browser window and confirm the visible row count (and therefore the mounted range size) adjusts correctly on the next scroll or resize event.
- Jump-scroll directly to the bottom via keyboard (
Endkey or scrollbar drag) and confirm the correct final rows render immediately, with spacer heights that leave no visible gap. - Check the DOM node count in the Elements panel before and after scrolling through the entire list — it should stay roughly constant at
overscanRows * 2 + visibleCount, not grow monotonically. - Collapse a sidebar or resize the browser window without scrolling, and confirm the
ResizeObserverfrom Step 5 mounts additional rows to fill the newly available space rather than leaving blank spacer visible. - Call
updateTotalRowswith a smaller value than the current scroll position implies (simulating a filter that removes most rows) and confirm the range clamps to the new bounds instead of leaving the view scrolled past the end of the data.
Alternative: Percentage-Based Overscan
The rowHeight * overscanRows formula in Step 3 produces a pixel-based rootMargin. For lists embedded in resizable panes — a split-view editor, a dashboard widget the user can drag to resize — a percentage-based rootMargin such as '50% 0px' scales the overscan automatically with the container's own height, rather than requiring a manual recalculation every time the pane is resized. The trade-off is less predictable behavior on very short or very tall containers, where a fixed percentage might produce an overscan of only a few rows or several dozen. Choose pixel-based overscan when row height is stable and known, and percentage-based overscan when the viewport itself is the more variable dimension.
Common Mistakes to Avoid
- Using
margin-topinstead ofposition: absolutefor row placement. Relying on document flow to position rows means every mount/unmount forces a reflow of every row below it. Absolute positioning with a precomputedtopoffset avoids this entirely for fixed-height rows. - Forgetting
overflow-anchor: noneon the spacers. Without it, the browser's scroll-anchoring heuristic can fight with your spacer resizing, producing a visible scroll jump exactly when the range recalculates. - Sizing
rootMarginin a fixed pixel value that ignoresrowHeight. An overscan of100pxmight be sixteen rows in a dense list and half a row in a card-based layout. Derive the overscan fromrowHeight * overscanRows, as shown in Step 3, so the buffer scales with row size. - Not re-observing sentinels after replacing the skeleton. If the list is ever torn down and rebuilt (for example, after a full data refresh), the old sentinel elements are gone. Call
disconnect()on the old observer and construct a freshWindowedListinstance rather than trying to reuse observer state across skeletons.
FAQ
Why use two sentinels instead of observing the scroll container directly?
The scroll container itself does not move relative to the viewport, so observing it tells you nothing about scroll position. Sentinels are placed inside the container's content, at the edges of the mounted range, so their crossing of the root bounds directly signals that the mounted range needs to shift.
What height should the spacer elements start at before any row has been measured?
Use a reasonable estimated row height, multiplied by the number of unrendered rows on that side. A common starting estimate is the height of your design system's default row or card component. The spacer height corrects itself automatically as rows are measured and the cache fills in with real values.
Does this pattern work if the list has a completely dynamic total row count?
Yes. The spacer and range calculations only depend on totalRows as an input, so appending or removing rows from the underlying data source just requires recomputing the bottom spacer height and re-running the range calculation on the next sentinel crossing or a manual trigger.
Related
- Virtual Lists & Windowing with Observers — the full anatomy of sentinel-driven windowing, including variable-height rows
- Recycling DOM Nodes in Observer-Driven Lists — reusing this same skeleton with a pooled row strategy
- Optimizing IntersectionObserver for 1000+ List Items — batching strategies for large observed sets
- Infinite Scroll & Pagination — the single-direction version of this sentinel pattern
↑ Back to Virtual Lists & Windowing with Observers