Swil
Frontend
Events & PerformanceJUL 25, 2026

The Loop That Lays Out 800 Times

Same reads, same writes, same pixels — one order is an order of magnitude slower. Measure it on your own machine.

Two loops. Same elements, same reads, same writes, same result on screen. One of them is an order of magnitude slower, and nothing in the code looks expensive. The difference is that one loop asks the browser a question it cannot answer without stopping to recompute the entire layout — once per iteration.

Livereal elements, real layout, your machine
Elements
Interleaved — read, write, read, write
Batched — all reads, then all writes

Best of 3 runs, performance.now() around the loop. Numbers vary by machine and browser.

These are real DOM nodes in real flow, and the timings are performance.now() around the actual loop — no simulated delay. Run both. The gap widens with element count, because the slow version does one full layout pass per element while the fast one does one for the whole batch.

Why the browser stops

Layout is lazy, and that laziness is the whole optimisation. Setting style.width does not lay anything out; it marks the tree dirty and moves on, so a hundred writes cost roughly one layout. The moment you read a geometric property — offsetWidth, getBoundingClientRect(), scrollTop, getComputedStyle() — the browser has to give you a truthful answer, and a dirty tree cannot produce one. It flushes. That flush is a forced synchronous reflow.

So the cost is not in the reads and not in the writes. It is in the alternation. Read-read-read costs one layout; read-write-read-write-read costs one per read.

Where it hides in real code

Almost nobody writes the interleaved loop on purpose. It appears when the read and the write live in different functions: a helper that measures an element, called from inside a loop that also positions it. It appears in forEach over a list of components where each one “just checks its own height”. It appears in scroll and resize handlers that measure, adjust, and measure again. Each call site looks innocent; the interleaving only exists at the loop level, where nobody is reading.

The scheduler

Once the pattern is separated into phases, you can enforce it structurally rather than by discipline: a queue where read() and write() tasks are collected and then flushed reads-first, once per frame. This is the shape libraries like fastdom ship, and it is about thirty lines. Callers no longer need to know what order they are called in — the scheduler guarantees the ordering globally.

The one subtlety worth stating: work queued during a flush must land in the next frame, not extend the current one. Draining the queues into locals before running them is what makes that true, and what stops a read that queues a write that queues a read from becoming an infinite frame.

The module

countForcedReflows below is the rule stated as code: a read costs a reflow exactly when a write has dirtied layout since the last one. The demo’s reflow counts come from it, and the scheduler underneath is the enforcement version.

layoutBatch.ts
/**
 * Layout thrashing: what it is, how to count it, and how to stop it.
 *
 * The browser keeps layout lazy. Writes to style are queued; layout is only
 * computed when something *reads* a geometric property (offsetWidth,
 * getBoundingClientRect, scrollTop, getComputedStyle…). That read cannot be
 * answered from a stale tree, so the browser stops and recalculates — a forced
 * synchronous reflow.
 *
 * One forced reflow is cheap. The disaster is the loop: read, write, read,
 * write. Every read after a write pays for a full layout pass, so a loop over N
 * elements does N layouts instead of one. Same code, same DOM, same result —
 * one or two orders of magnitude slower.
 *
 * The fix is not to do less work. It is to *reorder* the same work: batch all
 * reads, then all writes. Nothing is skipped; the browser just gets to compute
 * layout once.
 */

export type OpKind = "read" | "write";

/**
 * How many forced synchronous reflows a sequence of DOM operations costs.
 *
 * A read is free while layout is clean, and costs a reflow when a write has
 * dirtied it. So the count is simply "how many times does a read follow a
 * write". Layout starts clean, which is why a leading run of reads is free.
 */
export function countForcedReflows(ops: readonly OpKind[]): number {
  let reflows = 0;
  let dirty = false;
  for (const op of ops) {
    if (op === "write") {
      dirty = true;
    } else if (dirty) {
      reflows += 1;
      dirty = false;
    }
  }
  return reflows;
}

/**
 * Reorder a sequence into the batched form — every read, then every write.
 * The multiset of operations is unchanged, so the work is identical; only the
 * number of layout passes changes.
 */
export function batchOps(ops: readonly OpKind[]): OpKind[] {
  const reads = ops.filter((op) => op === "read");
  const writes = ops.filter((op) => op === "write");
  return [...reads, ...writes];
}

/** The interleaved pattern a naive `for` loop produces: read+write per item. */
export function interleavedOps(count: number): OpKind[] {
  return Array.from({ length: count * 2 }, (_, index) => (index % 2 === 0 ? "read" : "write"));
}

// ---------------------------------------------------------------------------
// A read/write scheduler — the shape libraries like fastdom ship.
// ---------------------------------------------------------------------------

export interface FrameScheduler {
  /** Queue a measurement. All reads in a frame run before any write. */
  read(task: () => void): void;
  /** Queue a mutation. */
  write(task: () => void): void;
  /** Run everything queued so far, reads first. Returns ops in run order. */
  flush(): OpKind[];
  readonly pending: number;
}

/**
 * Batches reads and writes into one flush per frame.
 *
 * `schedule` is injected so this is testable without a browser — pass
 * `requestAnimationFrame` in the app, a manual trigger in a test. The queues
 * are drained into locals before running, so a task that queues more work lands
 * in the *next* frame instead of extending the current one into an infinite
 * loop.
 */
export function createFrameScheduler(
  schedule: (callback: () => void) => void = (callback) => callback(),
): FrameScheduler {
  let reads: Array<() => void> = [];
  let writes: Array<() => void> = [];
  let scheduled = false;

  const flush = (): OpKind[] => {
    const pendingReads = reads;
    const pendingWrites = writes;
    reads = [];
    writes = [];
    scheduled = false;

    const order: OpKind[] = [];
    for (const task of pendingReads) {
      task();
      order.push("read");
    }
    for (const task of pendingWrites) {
      task();
      order.push("write");
    }
    return order;
  };

  const ensureScheduled = () => {
    if (scheduled) return;
    scheduled = true;
    schedule(() => {
      // `scheduled` may already be false if something flushed manually; the
      // flush itself is idempotent on empty queues, so this stays safe.
      flush();
    });
  };

  return {
    read(task) {
      reads.push(task);
      ensureScheduled();
    },
    write(task) {
      writes.push(task);
      ensureScheduled();
    },
    flush,
    get pending() {
      return reads.length + writes.length;
    },
  };
}

/**
 * Geometric properties whose *reads* force layout. Not exhaustive, but these
 * are the ones that actually show up in application code.
 */
export const LAYOUT_FORCING_READS: readonly string[] = [
  "offsetWidth",
  "offsetHeight",
  "offsetTop",
  "offsetLeft",
  "clientWidth",
  "clientHeight",
  "clientTop",
  "clientLeft",
  "scrollWidth",
  "scrollHeight",
  "scrollTop",
  "scrollLeft",
  "getBoundingClientRect",
  "getClientRects",
  "getComputedStyle",
  "innerText",
  "focus",
  "scrollIntoView",
];

/** Does reading this property force the browser to flush pending layout? */
export function forcesLayout(property: string): boolean {
  return LAYOUT_FORCING_READS.includes(property);
}