A variable-height virtual list estimates each row's height, renders the visible window, measures real heights with a shared ResizeObserver as rows mount and whenever they change, updates a prefix-sum offset index, and adjusts scrollTop by the height change of any row above the viewport so the content the user is looking at does not move.

Problem / Scenario Context

A support inbox virtualises thousands of conversations. Rows vary in height — one-line subjects, two-line previews, rows with attachment chips — and a few lines of text can wrap differently at each window width. The first implementation assumed a fixed 72 px row height: the scrollbar lied, rows overlapped, and jumping to a conversation landed a few rows off. The second measured rows with getBoundingClientRect() after render, which fixed the layout but caused visible jumps when images inside rows loaded after the measurement and when the user scrolled up into rows whose estimates had been wrong.

Windowing with variable heights is a measurement problem. The Virtual Lists & Windowing with Observers topic covers windowing; this page covers heights.

Mechanics Explanation

A virtual list positions rows absolutely (or with a spacer) using an offset index: offset[i] = sum of heights of rows 0..i−1. The window to render is found by searching the index for the scroll position. With unknown heights:

  1. Estimate each row's height (a constant, or per row type).
  2. Render the rows in the window at their estimated offsets.
  3. Measure actual heights after layout.
  4. Correct the index: every row after a corrected row shifts by the difference.
  5. Compensate scroll: if the corrected row is above the current viewport, the content the user sees would shift by the difference, so add it to scrollTop in the same frame.

ResizeObserver is the ideal measuring tool: it reports each mounted row's size right after layout — before paint — and keeps reporting whenever the row's height changes later (images decoding, fonts loading, "show more" toggles, width changes). Correction and compensation inside the callback land in the same frame, so nothing visibly jumps.

A plain array prefix sum costs O(n) per correction; a Fenwick tree (binary indexed tree) makes both correction and lookup O(log n), which matters beyond tens of thousands of rows.

Estimate, Render, Measure, Correct, CompensateFive steps. Each row starts with an estimated height in the offset index. The visible window is rendered at estimated offsets. A shared ResizeObserver reports the real height of each mounted row after layout. The offset index is corrected for every row whose height differs. If the corrected row lies above the viewport, scrollTop is adjusted by the difference in the same frame.1EstimateConstant or per-type estimate for every row.2Render windowRows at estimated offsets; only the visible slice exists.3Measure (RO)Real border-box heights after layout, before paint.4Correct indexUpdate the prefix sums from the corrected row onward.5CompensateIf above the viewport: scrollTop += difference, same frame.

Comparison Table: Height Strategies

Strategy Accurate scrollbar Handles late growth Jump risk Cost
Fixed height only if truly fixed no overlaps none
Measure once with getBoundingClientRect after visiting no on late growth forced layout per row
RO measure + correct, no compensation converges yes on scroll up low
RO measure + correct + compensate converges yes none low
Pre-measure off-screen yes no on late growth high up front

Minimal Reproducible Example

TypeScript
const ROW = 72;                                   // assumed height
function render(scrollTop: number): void {
  const first = Math.floor(scrollTop / ROW);
  const rows = data.slice(first, first + 20);
  container.replaceChildren(...rows.map((r, i) => {
    const el = renderRow(r);
    el.style.transform = `translateY(${(first + i) * ROW}px)`;
    return el;
  }));
}

declare const data: unknown[]; declare const container: HTMLElement;
declare function renderRow(r: unknown): HTMLElement;

Two-line rows overlap the next row; the scrollbar length is wrong; jumping to row 500 lands in the wrong place.

Production-Safe Solution

TypeScript
class Fenwick {
  #t: Float64Array;
  constructor(n: number, init: number) {
    this.#t = new Float64Array(n + 1);
    for (let i = 1; i <= n; i++) { this.#t[i] += init; const j = i + (i & -i); if (j <= n) this.#t[j] += this.#t[i]; }
  }
  add(i: number, delta: number): void { for (i++; i < this.#t.length; i += i & -i) this.#t[i] += delta; }
  prefix(i: number): number { let s = 0; for (; i > 0; i -= i & -i) s += this.#t[i]; return s; }  // sum of rows < i
  /** Largest i with prefix(i) <= y — the row at scroll position y. */
  find(y: number): number {
    let pos = 0, rem = y, step = 1 << Math.floor(Math.log2(this.#t.length - 1 || 1));
    for (; step; step >>= 1) if (pos + step < this.#t.length && this.#t[pos + step] <= rem) { pos += step; rem -= this.#t[pos]; }
    return pos;
  }
}

export class VariableList {
  #heights: Float64Array;
  #index: Fenwick;
  #rowOf = new WeakMap<Element, number>();
  #ro: ResizeObserver;

  constructor(private scroller: HTMLElement, private spacer: HTMLElement, count: number, private estimate = 72) {
    this.#heights = new Float64Array(count).fill(estimate);
    this.#index = new Fenwick(count, estimate);
    this.spacer.style.blockSize = `${count * estimate}px`;

    this.#ro = new ResizeObserver((entries) => {
      const viewTop = this.scroller.scrollTop;            // one read, layout is clean here
      let above = 0, total = 0;
      for (const e of entries) {
        const i = this.#rowOf.get(e.target);
        if (i === undefined) continue;
        const h = e.borderBoxSize[0].blockSize;
        const delta = h - this.#heights[i];
        if (Math.abs(delta) < 0.5) continue;
        this.#heights[i] = h;
        this.#index.add(i, delta);
        total += delta;
        if (this.#index.prefix(i) + h - delta <= viewTop) above += delta;   // row ended above the view
      }
      if (!total) return;
      this.spacer.style.blockSize = `${this.#index.prefix(count)}px`;
      if (above) this.scroller.scrollTop = viewTop + above;                // keep visible content still
      this.layoutMounted();
    });
  }

  mount(el: HTMLElement, i: number): void { this.#rowOf.set(el, i); this.#ro.observe(el, { box: 'border-box' }); }
  unmount(el: HTMLElement): void { this.#ro.unobserve(el); this.#rowOf.delete(el); }
  offsetOf(i: number): number { return this.#index.prefix(i); }
  rowAt(y: number): number { return this.#index.find(y); }
  layoutMounted(): void { /* re-apply translateY(offsetOf(i)) to mounted rows */ }
}

Rows are observed as they mount and unobserved as they unmount, so the observer only ever tracks the window. Every height change — first measurement, image decode, font swap, width-driven re-wrap — arrives through the same callback, before paint. The callback corrects the Fenwick tree, resizes the spacer, compensates scrollTop for rows that ended above the viewport, and re-positions mounted rows, all in one frame.

Correcting a Row Above the ViewportA scroll container showing rows above the viewport, rows in view and the spacer. A row above the viewport turns out to be forty pixels taller than estimated. Without compensation, everything in view would move down by forty pixels. With compensation, scrollTop increases by forty in the same frame and the rows in view stay in place.row 118 above view — measured +40pxrows in view — must not movesame rows if uncompensated: shifted +40pxSolid blue frame: viewport (root).scrollTop += 40 in the ResizeObserver callback keeps the reader's rows exactly where they were.

Choosing the Estimate

Better estimates mean smaller corrections and a more truthful scrollbar before rows are visited:

  • Per type: rows with attachments, rows with two-line previews and plain rows can each have their own estimate.
  • Running average: after measuring a few hundred rows, use their mean as the estimate for unmeasured rows, and rescale the spacer. The scrollbar thumb then settles quickly.
  • Server hints: if the backend knows text length, a cheap character-count heuristic predicts wrapping well for a known font and width.
  • Width changes: when the list's width changes, all heights may change; keep the measured heights (they are corrected as rows re-render) but re-estimate unmeasured ones from the new width.

Jump-to-row accuracy depends on the offset index: with the Fenwick tree, offsetOf(i) for an unvisited row uses estimates, so jumping lands close, and the correction pass after mount snaps it exactly — compensating so the target row stays at the top.

Scrollbar Error as Rows Are MeasuredA line chart of the total height error of the list as more rows are measured. With a fixed estimate of seventy-two pixels the error falls slowly from about twenty percent. With a running-average estimate updated as rows are measured, the error falls quickly to under three percent after a few hundred rows.05.51116.52202004006008001000rows measured (of 10,000)total height error (%)fixed 72px estimaterunning-average estimate

Verification Steps

  • Scroll up from deep in the list; rows above being measured must not move the visible rows.
  • Load images slowly in rows above the viewport and confirm no jumps.
  • Resize the window so rows re-wrap and confirm the list re-settles without overlap.
  • Jump to a row index and confirm it ends up exactly at the top after measurement.
  • Heap snapshot: the observer tracks only mounted rows.

Common Mistakes to Avoid

  • Measuring with getBoundingClientRect after render. It forces layout per row and misses later growth.
  • Correcting without compensating. Rows above the viewport move the content under the reader.
  • Observing unmounted rows. Unobserve on unmount, or the observer retains detached nodes.
  • An O(n) offset update per correction on very long lists.

FAQ

Why border-box rather than content-box?

Rows are stacked by their full height, including padding and border. borderBoxSize gives exactly the space each row occupies in the list.

Why is it safe to set scrollTop inside the ResizeObserver callback?

Scrolling does not change the observed rows' sizes, so it cannot re-trigger the observer. Doing it in the callback, before paint, is what keeps the adjustment invisible.

Does overflow-anchor make compensation unnecessary?

Scroll anchoring can handle some cases, but virtual lists position rows absolutely, which anchoring does not track reliably. Explicit compensation is predictable; disable anchoring on the scroller to avoid double adjustment.

Is a Fenwick tree overkill?

For a few thousand rows, a plain prefix-sum array updated lazily is fine. Beyond tens of thousands, O(log n) updates and lookups keep every correction and every scroll lookup cheap.

How do I handle rows that change height while visible?

The same way: the observer reports the change, the index is corrected, and rows below shift. Only rows above the viewport need scroll compensation.

What about horizontal virtual lists?

Use inlineSize instead of blockSize and scrollLeft instead of scrollTop; the structure is identical.


↑ Back to Virtual Lists & Windowing with Observers