Virtualizing Long Bar Charts with Windowing

A horizontal bar chart with a hundred thousand categories renders every bar into the DOM, freezes the tab for seconds on load, and janks on every scroll — even though only about forty bars are ever visible at once.

The fix is windowing: produce marks only for the slice of rows that intersect the viewport, and reuse those marks as the user scrolls. This page sits within large-dataset rendering strategies as the reduction that changes nothing about the data — it draws the full dataset faithfully, just never more than a screenful at a time. Unlike LTTB downsampling, which discards samples, windowing keeps every row and simply defers producing its mark until it scrolls into view.

Diagnostic checklist

Windowed rendering of a tall bar list A full-height spacer sets the scroll range while only the rows intersecting the viewport plus a small overscan are actually drawn. Draw the window, size the spacer for the rest full content spacer height = rows x rowHeight viewport overscan (top) overscan (bottom) ~40 bars drawn of 100,000 rows start = floor(scrollTop / rowH)
An empty spacer element carries the full scroll height so the scrollbar stays proportional, while only the rows intersecting the viewport plus a small overscan margin are drawn and recycled on scroll.
Per-frame time vs the 16.7ms budget Measured-style frame durations with bars over budget marked in red. frame #ms/frame16.7ms budget (60fps)
Frame durations for long bar charts with windowing against the 16.7ms (60fps) budget — the red bars are the janky frames a fix must remove.

Broken vs fixed

// ❌ BROKEN: every row becomes a DOM node up front.
function renderAll(rows: readonly Bar[], root: HTMLElement): void {
  for (const bar of rows) {                 // 100,000 nodes = seconds of layout
    const el: HTMLDivElement = document.createElement("div");
    el.style.width = `${bar.value}px`;
    el.textContent = bar.label;
    root.appendChild(el);                    // reflow storm; scroll then janks
  }
}
// ✅ FIXED: a spacer sets scroll range; only the visible window is drawn.
interface Bar { label: string; value: number }

class WindowedBarList {
  private readonly rowHeight = 24;           // fixed row height (px)
  private overscan = 4;                       // rows drawn beyond each edge

  constructor(
    private readonly rows: readonly Bar[],
    private readonly viewport: HTMLElement,   // the scrolling container
    private readonly spacer: HTMLElement,     // empty element sized to full height
    private readonly layer: HTMLElement,      // absolutely-positioned draw layer
  ) {
    // PERF: the spacer alone carries full height; no per-row nodes exist for it.
    spacer.style.height = `${rows.length * this.rowHeight}px`;
    viewport.addEventListener("scroll", this.onScroll, { passive: true });
    this.draw();
  }

  private onScroll = (): void => {
    // PERF: coalesce bursts of scroll events into one draw per frame.
    requestAnimationFrame(this.draw);
  };

  private draw = (): void => {
    const { scrollTop, clientHeight } = this.viewport;
    const first: number = Math.max(0, Math.floor(scrollTop / this.rowHeight) - this.overscan);
    const visible: number = Math.ceil(clientHeight / this.rowHeight) + this.overscan * 2;
    const last: number = Math.min(this.rows.length, first + visible);

    this.layer.replaceChildren();             // recycle: clear last window's marks
    this.layer.style.transform = `translateY(${first * this.rowHeight}px)`;
    for (let i = first; i < last; i++) {
      const el: HTMLDivElement = document.createElement("div");
      el.style.height = `${this.rowHeight}px`;
      el.style.width = `${this.rows[i].value}px`;
      el.textContent = this.rows[i].label;
      // A11Y: expose position in the full set so screen readers announce "row i of N".
      el.setAttribute("aria-setsize", String(this.rows.length));
      el.setAttribute("aria-posinset", String(i + 1));
      this.layer.appendChild(el);
    }
  };
}

The three moving parts are the spacer, the window computation, and the offset. The spacer is a single empty element whose height equals rows.length * rowHeight; it makes the scrollbar thumb the right size and lets native scrolling do all the work, with no JavaScript intercepting the wheel. The window is derived purely from scrollTop — divide by row height to get the first visible index, add a viewport’s worth of rows plus overscan — so it costs O(1) regardless of dataset size. The offset translates the small draw layer down to where those rows belong, so a handful of recycled marks appear at the correct scroll position instead of at the top.

Step-by-step fix

function visibleWindow(scrollTop: number, clientHeight: number, rowHeight: number, total: number, overscan: number): [number, number] {
  const first: number = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
  const count: number = Math.ceil(clientHeight / rowHeight) + overscan * 2;
  return [first, Math.min(total, first + count)];
}

Verification

const total = 100_000, rowHeight = 24, clientHeight = 600, overscan = 4;
const [first, last] = visibleWindow(50_000 * rowHeight, clientHeight, rowHeight, total, overscan);
console.assert(last - first <= Math.ceil(clientHeight / rowHeight) + overscan * 2, "window too large");
console.assert(first >= 0 && last <= total, "window out of bounds");
console.assert(first === 50_000 - overscan, `expected first 49996, got ${first}`);

In DevTools, scroll the list and watch the Elements panel: the draw layer’s child count should stay roughly constant (viewport rows plus overscan) no matter how far you scroll, while the scrollbar thumb reflects the full hundred-thousand-row height. A flat node count during scroll is the signal that recycling works.

Canvas windowing and variable heights

On a canvas the same window computation applies, but instead of recycling DOM nodes you clear the visible region and redraw only the bars in [first, last), translating the context by the fractional scroll offset so partial rows at the top and bottom clip cleanly. Because a canvas has no scrollbar of its own, keep the empty spacer inside a scrolling wrapper and draw the canvas as a position: sticky overlay; the wrapper provides native scrolling and the correct thumb size while the canvas paints the window.

Variable row heights break the plain-division index maths. When bars differ in height, precompute a cumulative-offset array once and binary-search it for the first row whose offset exceeds scrollTop. The search is O(log n), still negligible, and the spacer height becomes the last cumulative offset rather than a simple product. Measure-on-render approaches that grow the offset table as rows are seen also work but introduce scroll-anchor jumps, so a one-time measurement pass is steadier when heights are known ahead of time.

Edge cases & gotchas

  • Scroll anchoring on prepend. Inserting rows above the current view shifts every offset; adjust scrollTop by the inserted height in the same frame or the viewport visibly jumps.
  • Sub-pixel row heights. A fractional rowHeight accumulates rounding error over tens of thousands of rows; round the spacer height and offset to whole pixels to keep the last row reachable.
  • Momentum scroll on touch. Fast flick scrolling can outrun a throttled redraw; keep overscan generous enough that the layer never shows blank rows mid-flick.
  • Focus loss on recycle. Replacing the layer’s children destroys any focused element inside it; restore focus to the equivalent recycled node or keyboard users lose their place.
  • Very large spacer heights. Browsers cap element height (often around 33 million pixels); past that many rows, scale the spacer and map scroll position proportionally instead of one pixel per unit.
  • Horizontal bar labels. Long labels that overflow the bar must be clipped or truncated per row; measuring them per frame reintroduces layout cost, so cache label widths once.
Batched vs interleaved layout reads Reflow counts when reads and writes are batched versus interleaved. forced reflowsbatched read4batched write5interleaved read62interleaved write63
Why long bar charts with windowing pays off: batching reads and writes (green/blue) keeps forced reflows low; interleaving them (red) multiplies layout work.

Frequently Asked Questions

How is windowing different from downsampling?

Windowing renders the complete dataset without altering a single value; it just defers producing a mark until its row scrolls into view, so scrolling reveals every row eventually. Downsampling permanently reduces the number of samples to fit the pixel budget. Use windowing for scrollable lists where the user expects to reach every item, and downsampling for a fixed-width chart of a continuous series.

Why do I need a spacer element instead of intercepting the wheel?

The spacer lets native scrolling run, which gives you a correctly sized scrollbar thumb, momentum, keyboard paging, and accessibility for free. Intercepting the wheel to fake scrolling means reimplementing all of that and usually feels wrong on touch devices. The spacer’s only job is to occupy the full content height so the browser’s own scroll machinery behaves as if every row were present.

What happens if my rows have different heights?

Plain division stops working, so precompute a cumulative-offset array and binary-search it to find the first visible row from scrollTop. The spacer height becomes the final cumulative offset rather than a row-count times row-height product. The search is logarithmic and stays negligible even for a hundred thousand rows.