Two fronts of the baseline remain and this is the one that dominates the table: row 5 is still at 310 ms per render and row 9 at 7,812 nodes, with 09-01's Bottom-Up pointing out that 600 calls to paintCard cost 186 ms of self time and that there are 41 style recalculations where there should be one. That work is not slow JavaScript —you already optimized that in 09-02— nor retained memory —you already released that in 09-03—: it is the cost of talking to the DOM. This lesson explains why that conversation is expensive: the browser's rendering pipeline and which operations wake up each of its phases; what exactly a reflow is and what forces one synchronously, closing the warning left open in 06-02; how to recognize and eliminate layout thrashing, which is where those 41 recalculations come from; how much grouping insertions with DocumentFragment and replaceChildren (06-05) and updating with reconcile instead of redrawing (06-06) really contribute, with an honest assessment of each; why only transform and opacity animate for free; what will-change, contain and content-visibility do; and, finally, virtualization: painting only the rows you can see, implemented twice —with IntersectionObserver and with a computed window—, with its real trade-offs on the table. By the end, rows 5 and 9 will be closed.

Contents

  1. Why the DOM is expensive
  2. The rendering pipeline, phase by phase
  3. Which operation wakes up which phase
  4. Reflow, repaint and forced synchronous layout
  5. The blacklist: which reads force a reflow
  6. Layout thrashing: the loop that alternates reading and writing
  7. The read-then-write pattern, measured over the 600 cards
  8. Grouping insertions: DocumentFragment and replaceChildren
  9. Updating instead of redrawing: what reconcile really contributes
  10. Writing only what changes
  11. Cheap animation: transform and opacity
  12. Isolating the work: will-change, contain and content-visibility
  13. Syncing with requestAnimationFrame (and not with setTimeout)
  14. Virtualization I: sentinels with IntersectionObserver
  15. Virtualization II: the computed window with fixed heights
  16. The trade-offs of virtualizing
  17. Event delegation, now with numbers
  18. Rows 5 and 9, closed
  19. Symptom → likely cause → solution
  20. Common Mistakes and Tips
  21. Exercises
  22. Conclusion

  1. Why the DOM is expensive

There is a sentence people repeat a lot that, put like that, is false: "the DOM is slow". The DOM is not slow; it is something else. And confusing the two ideas leads to the wrong optimizations.

When you write li.textContent = 'Redesign the multipurpose room' you are not assigning a property on a JavaScript object. You are crossing a boundary: your JavaScript engine (V8) is asking something of the rendering engine (Blink), which keeps its own representation of the document in C++, with its own style and layout trees. That call has three distinct costs and it is worth separating them:

Cost What it is Typical magnitude
Crossing the boundary The jump from JavaScript to the native implementation, with type conversion Nanoseconds. Almost always irrelevant
Invalidating Marking that the style, geometry or pixels of an area are now out of date Very cheap, but it accumulates
Recomputing Actually redoing the style, the layout or the paint Milliseconds. This is where everything is

The key, and it is the central idea of the lesson, is that invalidating and recomputing are decoupled. The browser recomputes nothing when you write: it notes that there is pending work and carries on running your code. It will recompute once, just before painting the next frame, however many writes you have made. It is an automatic batching mechanism, and it is excellent.

Everything that makes DOM manipulation slow consists, at bottom, of breaking that batching: forcing the browser to recompute in the middle of your code, over and over, when it could have done it once at the end.

// This does NOT cause 600 recalculations. It causes 600 invalidations and ONE recalculation.
for (const li of cards) {
  li.classList.add('task--highlighted');
}
// ← here, before the next frame, a single Recalculate Style
// ✗ This DOES cause 600 recalculations, and it is 40× slower
for (const li of cards) {
  li.classList.add('task--highlighted');
  console.log(li.offsetHeight);        // ← a read: forces a recalculation NOW
}

The difference between the two loops is a single line. Understanding why that line costs so much means looking at the pipeline.

  1. The rendering pipeline, phase by phase

To turn HTML, CSS and JavaScript into pixels, the browser runs a fixed sequence of phases. It is called the rendering pipeline and it runs, at most, once per frame.

flowchart LR
    JS["1 · JavaScript<br/>your code mutates<br/>the DOM and the CSSOM"] --> E["2 · Style<br/><i>Recalculate Style</i><br/>which rules apply<br/>to each element"]
    E --> L["3 · Layout<br/><i>Reflow</i><br/>where each box goes<br/>and how big it is"]
    L --> P["4 · Paint<br/><i>Paint</i><br/>filling in pixels<br/>on each layer"]
    P --> C["5 · Composite<br/><i>Composite</i><br/>the GPU stacks<br/>the layers"]
    C --> F(["Frame<br/>on screen"])

What each phase does exactly:

1 · JavaScript. Your code runs and modifies the DOM tree, the classes, the inline styles or the content. Changes can also be triggered by CSS (an animation, a :hover) or by the Web Animations API, with no JavaScript involved at all.

2 · Style (Recalculate Style). The browser decides, for every affected element, which CSS rules apply to it and what the final value of each property is. The cost grows with the number of invalidated elements and with the complexity of the selectors. In DevTools it appears in purple.

3 · Layout (also reflow). With the styles resolved, the browser computes the geometry: the position and size of every box. It is the most expensive phase because it is global by nature: changing the width of one element can shift all its siblings, its parent and half the page. Also purple.

4 · Paint. The pixels are filled in: backgrounds, text, borders, shadows. It is not drawn directly on screen, but onto one or more layers. In DevTools, green.

5 · Composite. The layers are stacked in the right order and sent to the screen. This phase is done by the GPU and is dirt cheap. Also green.

And here is the observation that governs half the decisions in this lesson: you can enter the pipeline halfway through. Not every change forces a trip through all five phases.

flowchart TD
    A["You change width, top, font-size…"] --> B["Style → Layout → Paint → Composite"]
    C["You change color, background-color,<br/>box-shadow…"] --> D["Style → Paint → Composite<br/><b>(skips Layout)</b>"]
    E["You change transform, opacity"] --> F["Style → Composite<br/><b>(skips Layout and Paint)</b>"]
    style B fill:#fdd,stroke:#c00
    style D fill:#ffd,stroke:#c90
    style F fill:#dfd,stroke:#090

The fewer phases come into play, the cheaper the change. That is the whole theory of visual optimization, and the practical rules in sections 11 and 12 follow from it.

  1. Which operation wakes up which phase

Translated into concrete operations in your code, the table looks like this. It is worth keeping handy.

What you do Style Layout Paint Composite Relative cost
element.remove() / append() High
classList.add() that changes the size High
style.width / style.top / style.margin High
textContent = different text High
style.fontSize High
classList.add() that only changes color Medium
style.backgroundColor / boxShadow Medium
style.visibility Medium
style.transform / style.opacity Low
Writing to a node outside the tree Almost zero
dataset.x = ... with no CSS selector using it Low

Three consequences you can already apply to Nómada Tasks today:

  • Changing a card's text is expensive, because text can change width and that is geometry. Updating .task__meta on 600 cards when only two have changed is wasted work.
  • Building outside the tree is free. Everything you do to a freshly created <li> that has not been inserted yet costs nothing: no style, no layout, no paint. It is the underlying argument for DocumentFragment (06-05).
  • display: none and visibility: hidden are not the same. The first takes the element out of layout (and the change is expensive, but afterwards that element stops costing anything); the second keeps it taking up space and only avoids the paint.

  1. Reflow, repaint and forced synchronous layout

Let us pin down the vocabulary, because it is constantly misused:

  • Reflow (or layout): recomputing the geometry. That is phase 3.
  • Repaint: filling in pixels again without recomputing geometry. That is phase 4 without phase 3.
  • Forced synchronous layout: the browser having to run phases 2 and 3 in the middle of your JavaScript, because you asked for a value it can only give you if they are up to date.

The third is the one that concerns us. Go back to the mechanism in section 1: when you write, the browser only invalidates. But when you read a geometric property, the browser has a contract to fulfil: it must return the correct value at that instant. If there are pending invalidations, it has no choice but to stop everything, recompute style and layout, and only then answer you.

const li = $('[data-id="3"]');

li.style.width = '400px';            // 1 · invalidates. Recomputes nothing. Cheap
console.log(li.offsetWidth);         // 2 · READS geometry → forced recalculation HERE, synchronous

DevTools flags it explicitly: in the flame chart a purple block appears inside your yellow block, with a warning triangle and the text "Forced reflow is a likely performance bottleneck". If you see it, you have a section 6 problem.

An important nuance that saves false diagnoses: an isolated forced recalculation is not a problem. Measuring a column's height once to position a menu costs about 1–2 ms on the reference laptop at CPU 4×, and you will not notice it. The problem appears when that recalculation runs inside a loop, because each pass invalidates what the previous pass had just computed. That has a name of its own, and it is section 6.

  1. The blacklist: which reads force a reflow

This is the list that closes the warning from 06-02. You do not need to memorize the whole thing; you need to recognize the family: anything that returns a measurement, a position or an already resolved style.

Category Members Notes
Element geometry offsetTop, offsetLeft, offsetWidth, offsetHeight, offsetParent Rounded integers
Client geometry clientTop, clientLeft, clientWidth, clientHeight No borders, no scrollbars
Scrolling scrollTop, scrollLeft, scrollWidth, scrollHeight Writing scrollTop also forces
Rectangles getBoundingClientRect(), getClientRects() Decimals, includes transform
Resolved style getComputedStyle(el) and reading any of its properties The most treacherous
Rendered text innerText textContent does not force; innerText does
Focus and scrolling focus(), scrollIntoView(), scrollBy(), scrollTo() They need to know where the element is
Window window.scrollY, innerWidth, innerHeight, getComputedStyle
Others element.checkVisibility(), Range.getBoundingClientRect()

Three warnings almost nobody keeps in mind:

getComputedStyle is worse than it looks. It does not force the reflow when you call it, but when you read a property of the returned object, and it forces both the style and the layout recalculation if the property depends on geometry. Worse still: in practice it is impossible to predict which properties need it, so the safe rule is to treat the whole thing as an expensive read.

focus() is a read disguised as a write. The browser needs to know whether the element is visible and where it is in order to scroll to it. In 06-06 focus was restored after the render; if that happens in the middle of a loop of writes, you have one forced recalculation per pass.

textContent does not force; innerText does. innerText returns the text as it appears, so it needs to know what is hidden by CSS: that is style, and sometimes layout. textContent returns the tree's text and consults nothing. It is one more reason, on top of those in 06-02, to use textContent by default.

  1. Layout thrashing: the loop that alternates reading and writing

We have the mechanism. Let us go to the real Nómada Tasks case, which is where the 41 style recalculations and 38 layouts from 09-01's Bottom-Up come from.

The board of 600 tasks has 40 overdue ones. The card of an overdue task carries a badge reading "overdue by N days", and if the title is long the badge spills out of the box. The solution put in place at the time was to measure the title and, if it did not fit, shrink the card to a single line. Here is the code, exactly as it stands, in the BoardView from 09-03:

// ✗ js/view/board-view.js — the toxic loop
#adjustHeights(visible) {
  for (const task of visible) {
    if (!task.isOverdue(this.#state.today)) continue;      // only the 40 overdue ones

    const li = this.#nodesById.get(task.id);
    if (li === undefined) continue;

    const height = li.querySelector('.task__title').offsetHeight;   // ← READ
    li.style.setProperty('--title-height', `${height}px`);          // ← WRITE
    li.classList.toggle('task--long-title', height > 22);           // ← WRITE
  }
}

Look at it through the model from section 4. Pass 1: offsetHeight is read, the browser recomputes (nothing is pending the first time, so it is cheap) and answers. Then a CSS variable is written and a class is changed: invalidated. Pass 2: offsetHeight is read again, but now there is pending work, so the browser has to recompute style and layout for the whole invalidated subtree before answering. And so on, forty times.

flowchart TD
    subgraph BAD["✗ Alternating: 40 recalculations"]
        direction TB
        R1["read offsetHeight"] --> W1["write class"]
        W1 -->|"invalidated"| R2["read offsetHeight<br/><b>→ forced recalculation</b>"]
        R2 --> W2["write class"]
        W2 -->|"invalidated"| R3["read offsetHeight<br/><b>→ forced recalculation</b>"]
        R3 --> D1["… × 40"]
    end
    subgraph GOOD["✓ Grouping: 1 recalculation"]
        direction TB
        RR["read the 40 heights<br/><b>→ 1 recalculation</b>"] --> WW["write the 40 classes"]
        WW --> FF["the browser recomputes<br/>once, before painting"]
    end
    style R2 fill:#fdd,stroke:#c00
    style R3 fill:#fdd,stroke:#c00
    style RR fill:#dfd,stroke:#090

Forty forced reads, plus one of the scrollTop that is saved to preserve the scroll position (06-06): 41 style recalculations. And 38 layouts, three fewer because on three passes the previous write did not end up invalidating the geometry. The Bottom-Up numbers fit exactly:

Bottom-Up entry Occurrences Time Per occurrence
Recalculate Style 41 74 ms 1.80 ms
Layout 38 63 ms 1.66 ms
Thrashing total 137 ms of the render's 310 ms

Almost half the render goes on forty reads of offsetHeight. And most importantly: it is not a problem of how much code you run, but of what order you run it in. The same amount of work, reordered, costs a fraction.

This is the pattern 06-02 called layout thrashing and deferred to here. You now know how to recognize it: a read from the section 5 blacklist inside a loop that also writes.

  1. The read-then-write pattern, measured over the 600 cards

The solution is not to read less or write less: it is to separate the two phases. All the reads first, then all the writes. The browser recomputes once at the start of the read block and once, on its own initiative, before painting.

// ✓ js/view/board-view.js — read-then-write
#adjustHeights(visible) {
  const overdue = visible.filter((t) => t.isOverdue(this.#state.today));

  // ── PHASE 1 · READ. No writes in here. A single forced recalculation ──
  const measurements = overdue.map((task) => {
    const li = this.#nodesById.get(task.id);
    return li === undefined
      ? null
      : { li, height: li.querySelector('.task__title').offsetHeight };
  }).filter(Boolean);

  // ── PHASE 2 · WRITE. No reads in here. Only invalidations ──
  for (const { li, height } of measurements) {
    li.style.setProperty('--title-height', `${height}px`);
    li.classList.toggle('task--long-title', height > 22);
  }
}

The change is purely structural: the same reads, the same writes, the same visual result. The measurement, with bench() from 09-01 (5 warm-up runs, 15 measured runs, CPU 4×, 600 tasks of which 40 are overdue):

Variant Forced recalculations Recalculate Style Layout #adjustHeights
Alternating reading and writing 41 74 ms 63 ms 141 ms
Read-then-write 1 2.4 ms 1.7 ms 4.3 ms

Thirty-three times faster without changing a single operation. And the effect on row 5:

Measurement Before After
Full render(), 600 tasks 310 ms 179 ms

From 310 to 179 ms with a reordering. It is by far the best benefit-to-effort ratio in the whole lesson.

An honest warning about this pattern: it is fragile. Nothing stops somebody six months from now sticking a read into the write phase, and performance will collapse without a single test failing. There are three reasonable defenses:

First: a comment explaining the reason, not what the code does. // PHASE 1 · READ. No writes in here. is exactly that.

Second: encapsulate the pattern so that the separation does not depend on the discipline of whoever edits it.

// js/view/dom.js

/**
 * Runs ALL the reads first and then ALL the writes,
 * so the browser performs at most one forced recalculation.
 *
 * @param {Function} read   Must return the measured data. Must not write.
 * @param {Function} write  Receives whatever `read` returned. Must not read geometry.
 */
export function readThenWrite(read, write) {
  const measurements = read();
  write(measurements);
  return measurements;
}

Third: a test that counts the recalculations. You cannot count them directly from Jest with jsdom (which does no layout), but you can from Cypress or Puppeteer, observing the browser's performance entries. That is section 20's material, in the tips.

  1. Grouping insertions: DocumentFragment and replaceChildren

In 06-05 you learned about DocumentFragment with a promise: "the serious measurement is 09-04's subject". Time to keep it, and the answer is going to be less spectacular than most tutorials suggest.

The classic argument is that inserting 600 elements one by one causes 600 reflows. That was true in 2008 and today it is false, because of the mechanism from section 1: the browser invalidates and batches. Let us measure it, with three variants, over the initial paint of the 600 cards:

// A · Direct insertion, one at a time
for (const task of visible) list.append(paintCard(task, null, today));

// B · DocumentFragment
const fragment = document.createDocumentFragment();
for (const task of visible) fragment.append(paintCard(task, null, today));
list.append(fragment);

// C · replaceChildren with spread (the form adopted in 06-05)
list.replaceChildren(...visible.map((t) => paintCard(t, null, today)));

// D · Direct insertion WITH a geometry read in the middle
for (const task of visible) {
  list.append(paintCard(task, null, today));
  list.scrollHeight;                     // ← a single line changes everything
}
Variant 600 cards Versus A
A · append one at a time 131 ms
B · DocumentFragment 120 ms 1.09×
C · replaceChildren(...) 119 ms 1.10×
D · append + one read per pass 2,140 ms 0.06×

Read it carefully, because there are two conclusions and they point in different directions.

The fragment contributes 9%. Real, measurable and free, but it is not what fixes anything. Modern browsers already batch layout work, so the difference between 600 insertions and one comes down to the cost of the calls themselves and to a few invalidations that can be merged better. Nine per cent is not negligible, but anyone who thinks DocumentFragment is "the" DOM optimization is looking in the wrong place.

The read in the loop multiplies by sixteen. Variant D is the same work with one extra line, and it costs 2.1 seconds. That is the order of magnitude, and it is the same as in section 6: the enemy is not touching the DOM many times, it is interleaving reads.

All that said, there are still two solid reasons to use replaceChildren(...):

  • Readability. A single line saying "the content of this list is exactly this" reads better than a loop with an append.
  • Visual atomicity. Emptying and refilling in two separate operations can leave, in rare cases with asynchronous work in between, one frame with an empty list. replaceChildren cannot.

And a third reason that is pure performance, though less well known: when the container is outside the tree or inside a content-visibility: hidden (section 12), all the construction work is free, and there the fragment really is the natural tool.

Technique Real benefit When to use it
DocumentFragment ~9% on bulk insertions Complex construction outside the tree
replaceChildren(...) ~10% and a lot of readability Full replacement of a list
innerHTML = string Fast but unsafe (06-05) Never with user data
reconcile() Depends on the change (section 9) Partial updates
Not touching the DOM 100% Section 10

  1. Updating instead of redrawing: what reconcile really contributes

In 06-06 you built reconcile(container, data, key, paint), which reuses existing nodes instead of destroying and recreating them. It was justified on correctness grounds: preserving focus, scroll position and transitions. Now for the performance question: how much does it save?

The honest answer is: it depends entirely on how much changes, and the variability is enormous.

Measurement over the 600 tasks, comparing replaceChildren(...) (recreate everything) with reconcile (reuse), across four real Nómada Tasks scenarios:

Scenario Changes replaceChildren reconcile Improvement
Marta marks a task as done 1 card moves column 119 ms 3.1 ms 38×
A batch of 12 updates arrives over WebSocket 12 cards change 119 ms 9.4 ms 13×
Iván types "scre" in the search box 47 cards out of 600 remain 119 ms 21 ms 5.7×
Lucía changes the sort to "by date" 600 cards are reordered 119 ms 142 ms 0.84×

Look at the last row: reconciling can be slower. When the order of every element changes, the simple implementation from 06-06 does one insertBefore per node —600 moves in the live tree— plus 600 content updates, and that costs more than building the whole list from scratch outside the tree. It is exactly the limit 06-06 announced: "it does not detect moves optimally".

The practical conclusion is not "do not use reconcile", but something more nuanced:

reconcile wins hands down in the frequent case —small changes to a stable list— and loses in the rare case of a full reorder. Since the frequent case is the one that dominates perceived experience, it is still the right choice. And the rare case stops mattering as soon as there are only 48 nodes on screen, which is what the virtualization in section 15 achieves.

That last nuance is important: optimizations interact. With virtualization, a full reorder affects 48 cards instead of 600, and the bad row of the table disappears on its own.

  1. Writing only what changes

Let us go back to the entry that tops the Bottom-Up: paintCard, 600 calls, 186 ms of self time. There is no thrashing left, so that time is real work. Where does it go?

Look at the 06-06 function with performance eyes. For each card it unconditionally does:

  • 4 writes to dataset
  • 1 classList.remove(...) with three classes and 3 classList.add/toggle
  • 3 textContent assignments
  • 1 replaceChildren of the tag list, which destroys and recreates the tag <li>s
  • 2 writes of disabled and 2 setAttribute('aria-label', ...)

That is 16 writes per card, 9,600 in total, to update a list in which normally nothing has changed. And even though the browser batches, every write invalidates something, and every invalidation widens the subtree that will have to be recomputed afterwards.

The fix is the dullest and the most effective of the lesson: check before writing.

// js/view/dom.js

/** Assigns only if the value is different. Avoids invalidating for nothing. */
export function setText(node, value) {
  if (node.textContent !== value) node.textContent = value;
}

/** The same for attributes, including the removal case. */
export function setAttr(node, name, value) {
  if (value === null || value === false) {
    if (node.hasAttribute(name)) node.removeAttribute(name);
  } else if (node.getAttribute(name) !== String(value)) {
    node.setAttribute(name, value);
  }
}

/** The same for dataset. */
export function setData(node, key, value) {
  const text = String(value);
  if (node.dataset[key] !== text) node.dataset[key] = text;
}

And paintCard rewritten using them, plus an early exit that is what really matters:

// js/view/card.js
import { $ } from './dom.js';
import { setText, setAttr, setData } from './dom.js';
import { TODAY } from '../util/dates.js';
import { statusBadge } from '../util/format.js';

const PRIORITY_CLASSES = ['task--high', 'task--medium', 'task--low'];
const NEXT  = Object.freeze({ pending: 'in-progress', 'in-progress': 'done', done: null });
const LABEL = Object.freeze({
  pending: 'Start', 'in-progress': 'Mark done', done: 'Completed'
});

/** A fingerprint of what is visible about a task. If it does not change, the card is already correct. */
function fingerprint(task, today) {
  return `${task.status}|${task.priority}|${task.title}|${task.assignee}` +
         `|${task.estimatedHours}|${task.tags.join(',')}|${task.isOverdue(today)}`;
}

export function paintCard(task, existing = null, today = TODAY) {
  const li = existing ?? $('#task-template').content.firstElementChild.cloneNode(true);
  const next = fingerprint(task, today);

  // ── Early exit: nothing visible has changed. Zero DOM writes ──
  if (existing !== null && li.dataset.fingerprint === next) return li;
  li.dataset.fingerprint = next;

  const overdue = task.isOverdue(today);

  setData(li, 'id', task.id);
  setData(li, 'status', task.status);
  setData(li, 'assignee', task.assignee ?? '');
  setData(li, 'priority', task.priority);

  // classList already checks for itself: adding a class that is present does not invalidate
  li.classList.remove(...PRIORITY_CLASSES);
  li.classList.add(`task--${task.priority}`);
  li.classList.toggle('task--done', task.status === 'done');
  li.classList.toggle('task--overdue', overdue);

  setText($('.task__title', li), task.title);
  setText($('.task__meta', li),
    `${statusBadge(task.status)} ${task.assignee ?? 'unassigned'} · ` +
    `${task.estimatedHours} h` + (overdue ? ` · overdue by ${Math.abs(task.daysLeft)} d` : ''));

  // The tags almost never change: reconcile instead of recreating
  const container = $('.task__tags', li);
  if (container.dataset.value !== task.tags.join(',')) {
    container.dataset.value = task.tags.join(',');
    container.replaceChildren(...task.tags.map((text) => {
      const item = document.createElement('li');
      item.className = 'tag';
      item.textContent = text;
      return item;
    }));
  }

  const advance = $('[data-action="advance"]', li);
  setText(advance, LABEL[task.status]);
  advance.disabled = NEXT[task.status] === null;
  setAttr(advance, 'aria-label', `${LABEL[task.status]}: ${task.title}`);

  const reopen = $('[data-action="reopen"]', li);
  reopen.disabled = task.status !== 'in-progress';
  setAttr(reopen, 'aria-label', `Return to not started: ${task.title}`);

  return li;
}

Three comments about this code:

  • The fingerprint is the key. It is a cheap string to build (about 0.004 ms) that summarizes everything visible. Comparing it costs a fraction of what writing sixteen properties costs. It is, conceptually, the same idea as the version counter from 09-02: a cheap way of knowing whether any work is needed.
  • disabled is assigned without checking, deliberately: assigning a boolean property that already has that value invalidates nothing in current engines, and the check would cost more than the saving. The rule is to protect what invalidates —text, attributes, dataset with associated CSS selectors— not everything as a matter of course.
  • classList.add of an already present class does not invalidate either. classList is a DOMTokenList and it checks membership before modifying. That is the reason 06-02 insisted on using classList rather than hammering className: as well as being more readable, it is cheaper.

Measurement over the full render, after applying section 7:

Measurement After read-then-write With early exit and conditional writing
paintCard, self time (600) 149 ms 101 ms
DOM writes per render with no changes 9,600 0
Full render() 179 ms 142 ms

And a row that is not in the baseline but that is the most noticeable in daily use: the render after a single task changes goes from 3.1 ms to 0.8 ms, because 599 cards leave through the early exit.

  1. Cheap animation: transform and opacity

Let us change ground. So far we have talked about updating content; now, about moving it. Nómada Tasks animates the entrance of a new card and the slide of a card when it changes column. This is how the entrance was done:

/* ✗ css/styles.css — animates geometry: layout on every frame */
.task--entering {
  animation: enter 240ms ease-out;
}

@keyframes enter {
  from { height: 0;    margin-top: -12px; opacity: 0; }
  to   { height: 108px; margin-top: 0;    opacity: 1; }
}

Every frame of that animation changes height and margin-top, which are in the red row of section 3's table: style, layout, paint and composite. And since the <li> is in a container with display: grid, that layout does not affect only the card: it repositions the whole column.

The correct version expresses the same visual effect with the only two properties the GPU can animate on its own:

/* ✓ Only transform and opacity: skips layout and paint */
.task--entering {
  animation: enter 240ms cubic-bezier(0.2, 0, 0.2, 1);
}

@keyframes enter {
  from { transform: translateY(-12px) scaleY(0.96); opacity: 0; }
  to   { transform: none;                           opacity: 1; }
}

/* Accessibility: respect the system preference (07-06) */
@media (prefers-reduced-motion: reduce) {
  .task--entering { animation: none; }
}

The reference table, extending section 3's with the most commonly animated properties:

Animated property Style Layout Paint Composite Verdict
width, height Avoid it
top, left, right, bottom Avoid it
margin, padding Avoid it
font-size, line-height Avoid it
border-width Avoid it
color, background-color Acceptable
box-shadow, border-radius Acceptable, but painting shadows is expensive
visibility Acceptable
transform Use it
opacity Use it
filter Composited, but the GPU can suffer with large blur values

Measurement: animating the simultaneous entrance of 24 cards (what happens when a filter is removed), recording with the DevTools Frames lane at CPU 4×:

Animation Dropped frames Average FPS Work per frame
height + margin-top 38 of 58 22 fps Style + layout + paint + composite
transform + opacity 0 of 58 60 fps Composite only

The explanation of why the second one is free deserves a paragraph. When an animation affects only transform and opacity, the browser can promote the element to its own compositing layer: it paints it once, and on each frame it just tells the GPU "this layer, shifted 8 pixels and at opacity 0.7". There is no style to recompute, no geometry to redo, no pixels to fill. The main thread does not even notice: the animation keeps running even if your JavaScript is busy.

Hence a practical consequence that surprises people: a well-made CSS animation survives a long JavaScript task; one made with requestAnimationFrame does not. If you can express the effect in CSS or with the Web Animations API (element.animate(...), from 07-06), do so, and reserve requestAnimationFrame for whatever demands per-frame computation.

And the trick that solves 90% of the cases where "I need to animate position and I cannot use transform": the FLIP technique (First, Last, Invert, Play). You measure the initial position, apply the change in one go, measure the final position, and animate with transform from the difference back to zero.

// js/view/animate.js

/**
 * Animates a node's movement between two positions using only transform.
 * @param {HTMLElement} node
 * @param {Function} change  Function that applies the layout change in one go.
 */
export function flip(node, change) {
  const first = node.getBoundingClientRect();      // F · one read, outside any loop
  change();                                         // L · the real change, unanimated
  const last = node.getBoundingClientRect();        // L · one more read

  const dx = first.left - last.left;                // I · invert
  const dy = first.top - last.top;
  if (dx === 0 && dy === 0) return null;

  return node.animate(                              // P · play, composite only
    [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
    { duration: 220, easing: 'cubic-bezier(0.2, 0, 0.2, 1)' }
  );
}

Watch out for one thing: flip does two geometry reads. That is fine for one node, and it is exactly the section 6 mistake if you call it inside a loop over 600 cards. To animate several, you first read all the initial positions, then apply the change, then read all the final positions, and only then animate. Read-then-write again.

  1. Isolating the work: will-change, contain and content-visibility

These three CSS properties are pure performance tools: they do not change how anything looks, but how much work the browser can save itself.

12.1 will-change

It warns the browser that a property is about to change, so it can prepare what is needed —typically, promoting the element to its own layer— before the animation starts rather than on the first frame.

.task--dragging { will-change: transform; }

And now the three rules that make will-change help rather than hurt:

  1. Use it with extreme moderation. Every layer consumes GPU memory. Putting will-change: transform on all 600 cards can consume hundreds of megabytes of video memory and make the application slower. It is the most frequent misuse.
  2. Add it and remove it. The right approach is to add it just before (with a class, or on :hover over the ancestor) and remove it when done. Leaving it permanently in the CSS is exactly what you must not do.
  3. Do not use it to "fix" slow animations. If your animation touches height, will-change: height is not going to save it: the problem is the property, not the preparation.
// The correct pattern: enable just before, remove when finished
card.style.willChange = 'transform';
const animation = flip(card, () => column.append(card));
animation?.finished.finally(() => { card.style.willChange = 'auto'; });

12.2 contain

It promises the browser that whatever happens inside an element does not affect anything outside it. With that promise, the browser can limit the scope of a recalculation to the subtree instead of propagating it to the whole page.

Value What it promises
layout The internal geometry does not affect the external one
paint Nothing is drawn outside the element's bounds
size The element's size does not depend on its children (you must give it an explicit size)
style Certain style effects (counters) do not escape the subtree
content Shorthand for layout + paint + style
strict Shorthand for content + size
/* Each card is an island: changing its insides does not recompute the whole column */
.task { contain: layout paint; }

With this, updating a card's .task__meta can no longer shift the other 599, and the browser knows that in advance. In the measurement of the render after a single task changes, contain: layout paint brings Layout down from 1.9 ms to 0.3 ms.

The warning: contain: size is dangerous if you do not give the element dimensions, because then the browser will treat it as if it measured zero and the card will disappear. It is the classic mistake with this property.

12.3 content-visibility

It is the most powerful of the three and the one that comes closest to virtualization without writing any JavaScript. content-visibility: auto tells the browser: "if this element is not near the visible area, do not do its style, its layout or its paint".

.task {
  content-visibility: auto;
  contain-intrinsic-size: auto 108px;   /* estimated size while it is skipped */
}

contain-intrinsic-size is mandatory in practice: without it, the browser treats the skipped element as if it were 0 px tall, and the scrollbar jumps and shrinks grotesquely as you scroll. With the auto keyword, the browser remembers the real size once it has computed it and uses that value from then on, which almost entirely eliminates the jumping.

Measurement over the full render:

Measurement Without content-visibility With content-visibility: auto
Full render(), 600 tasks 142 ms 78 ms
Layout of the initial paint 41 ms 6 ms
Paint of the initial paint 33 ms 5 ms
Document nodes 7,812 7,812

Two readings, and the second is the important one.

It is an enormous saving for two lines of CSS. From 142 to 78 ms without touching JavaScript, without breaking anything, with no accessibility trade-offs. If you could only apply one technique from this lesson, it would be this one.

But it does not close row 9. The 7,812 nodes are still there: content-visibility avoids working with them, it does not remove them. And nodes cost memory (about 2.4 MB on the heap, measured with the Memory panel from 09-03), collection cost, and cost in every querySelectorAll that walks them. To get from 7,812 down to 1,500 you have to not create them, and that is virtualization.

An honest comparison of the three, to keep them straight:

Tool What it saves Cost Risk
will-change The first frame of an animation GPU memory per layer Overusing it and making things worse
contain Propagation of recalculations None if you do not use size size without dimensions breaks the layout
content-visibility Style, layout and paint of what is not visible Nothing significant Scroll jumping without contain-intrinsic-size

  1. Syncing with requestAnimationFrame (and not with setTimeout)

In 07-06 you used requestAnimationFrame to animate. Here it matters for another reason: it is the right moment to do visual work, and using setTimeout instead produces two distinct defects.

requestAnimationFrame(callback) schedules the callback to run just before the next paint. That means three guarantees setTimeout does not give:

setTimeout(fn, 16) requestAnimationFrame(fn)
When it runs Whenever the event loop gets to it, ~16 ms later Just before the next paint
Does it sync with the screen? No. At 120 Hz or 144 Hz it runs out of sync Yes, whatever the refresh rate
Hidden tab It keeps running and draining the battery It is paused
Several calls in the same task They run separately They are grouped into the same frame
Risk Double or dropped frames None

The subtlest defect of setTimeout for visual work is called a double frame: if your callback runs halfway between two paints, the browser can paint the same state twice and skip the next change, producing the characteristic stutter of timer-driven animations.

In Nómada Tasks there is one case where this genuinely matters: the scroll handler that decides which cards to paint in the virtual window of section 15. With throttle(fn, 100) from 09-02 the scrolling is jerky; with requestAnimationFrame, it is smooth. The correct pattern is the so-called coalesced rAF:

// js/util/time.js

/**
 * Runs `fn` at most ONCE per frame, with the latest arguments.
 * It is the correct `throttle` for visual work: it syncs with the screen
 * instead of with a fixed number of milliseconds.
 */
export function perFrame(fn) {
  let pending = 0;
  let lastArgs = null;

  const wrapped = function (...args) {
    lastArgs = args;
    if (pending !== 0) return;                   // one is already scheduled: do not stack up
    pending = requestAnimationFrame(() => {
      pending = 0;
      fn.apply(this, lastArgs);
    });
  };

  wrapped.cancel = () => {                       // mandatory cleanup (09-03)
    cancelAnimationFrame(pending);
    pending = 0;
  };

  return wrapped;
}
// js/view/board-view.js
import { perFrame } from '../util/time.js';

#onScroll = perFrame(() => this.#recomputeWindow());

constructor({ container, ... }) {
  // …
  this.#container.addEventListener('scroll', this.#onScroll, {
    passive: true,                          // we are not going to call preventDefault (09-02)
    signal: this.#controller.signal         // cleanup with a single abort() (09-03)
  });
}

Measurement of scrolling the not-started column with 600 tasks, 5 seconds of continuous scrolling, CPU 4×:

scroll handler Runs Dropped frames Average FPS
Unthrottled 312 71 34 fps
throttle(fn, 100) 51 24 48 fps
perFrame(fn) 58 2 59 fps

Notice that perFrame runs more times than the 100 ms throttle and still drops twelve times fewer frames. It is not a question of running rarely: it is a question of running at the right moment.

And the rule that sums up the section, with its nuance:

requestAnimationFrame for visual work, setTimeout/debounce for non-visual work. Filtering the board as you type is non-visual (the result is a computation): debounce. Repositioning the virtual window as you scroll is visual: requestAnimationFrame.

  1. Virtualization I: sentinels with IntersectionObserver

We arrive at the core issue. With 600 tasks there are 7,812 nodes in the document, and six cards per column fit on screen. We are keeping almost eight thousand nodes to show eighteen.

To virtualize is to paint only what is visible (plus a small margin) and fake the rest. There are two families of implementation, and it is worth seeing both because they solve different problems.

The first, simpler one, is infinite scrolling with a sentinel: you paint the first N cards and, when the user reaches the end, you paint another N. It is implemented with the IntersectionObserver from 07-06, which tells you when an element enters the visible area without having to listen to scroll.

// js/view/progressive-list.js
import { $ } from './dom.js';
import { buildElement } from './dom.js';

const BATCH = 30;                 // how many cards are added each time

/**
 * Paints a long list in batches: the first BATCH elements, and the rest
 * as the user gets close to the end.
 */
export class ProgressiveList {
  #container;
  #paint;
  #data = [];
  #painted = 0;
  #sentinel;
  #observer;

  constructor({ container, paint }) {
    this.#container = container;
    this.#paint = paint;

    // The sentinel is an empty element at the end of the list
    this.#sentinel = buildElement('li', {
      classes: 'sentinel', 'aria-hidden': 'true'
    });

    this.#observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) this.#nextBatch();
    }, {
      root: container.closest('.column'),
      rootMargin: '400px'          // paint BEFORE the gap becomes visible (07-06)
    });
  }

  setData(data) {
    this.#data = data;
    this.#painted = 0;
    this.#container.replaceChildren(this.#sentinel);
    this.#observer.observe(this.#sentinel);
    this.#nextBatch();
  }

  #nextBatch() {
    const end = Math.min(this.#painted + BATCH, this.#data.length);
    if (end === this.#painted) {
      this.#observer.unobserve(this.#sentinel);   // there is nothing left
      this.#sentinel.remove();
      return;
    }

    const fragment = document.createDocumentFragment();
    for (let i = this.#painted; i < end; i += 1) {
      fragment.append(this.#paint(this.#data[i], null));
    }
    this.#sentinel.before(fragment);              // insert BEFORE the sentinel
    this.#painted = end;
  }

  /** Mandatory cleanup: a live observer retains its nodes (09-03). */
  destroy() {
    this.#observer.disconnect();
    this.#sentinel.remove();
    this.#data = [];
  }
}

Four details of the code that deserve attention:

  • rootMargin: '400px' makes the sentinel "enter" four hundred pixels before it is visible. Without that margin, the user sees the empty gap for a moment. It is the same preloading trick as in 07-06.
  • The sentinel goes at the end and things are inserted before it, not after: that way it always stays last and the observer can keep using it.
  • unobserve + remove when the data runs out stops the observer from staying alive for no reason, and avoids the detached node from 09-03.
  • destroy() with disconnect() is mandatory: an IntersectionObserver observing a node keeps it reachable.

Measurement of the initial paint with this technique:

Measurement All at once Progressive, batches of 30
Time until the first card is visible 142 ms 11 ms
Nodes at startup 7,812 972
Nodes after scrolling to the end 7,812 7,812
Added complexity Low

The first row is excellent: the perception improves radically because the first cards appear almost immediately. But look at the third: the nodes accumulate. The progressive list reduces the initial cost, not the sustained cost. For Marta, who spends the morning scrolling through the board, we will end up back at 7,812 nodes.

Row 9 is asking for something else.

  1. Virtualization II: the computed window with fixed heights

Real virtualization keeps in the DOM only the elements of the visible window, creating those that come in and removing those that go out. The number of nodes becomes constant, independent of the size of the list.

The idea, when every row is the same size, is purely arithmetic:

flowchart TB
    subgraph V["Scrolling container · 640 px tall"]
        direction TB
        E1["Top spacer<br/>height = first × 116 px"]
        W["<b>Painted window</b><br/>16 real cards<br/>(6 visible + 5 of margin above and below)"]
        E2["Bottom spacer<br/>height = (600 − last) × 116 px"]
        E1 --> W --> E2
    end
    S["scrollTop"] -->|"first = floor(scrollTop / 116) − 5"| W

The two spacers are the key piece: an empty <li> at the top and another at the bottom, with exactly the height the unpainted cards would occupy. Thanks to them, the scrollbar has the right size and the position within the list is faithful, even though only sixteen cards exist.

// js/view/virtual-list.js
import { buildElement } from './dom.js';
import { perFrame } from '../util/time.js';

/**
 * Keeps only the visible rows of a long list in the DOM.
 * It requires ALL rows to be the same size (`rowHeight`).
 */
export class VirtualList {
  #container;                        // the <ul class="task-list">
  #viewport;                         // the scrolling element (the column)
  #paint;                            // (item, existingNode) => HTMLElement
  #key;                              // (item) => string|number
  #rowHeight;                        // the height of one row, in px, including its gap
  #overscan;                         // extra rows above and below
  #data = [];
  #topSpacer;                        // top spacer
  #bottomSpacer;                     // bottom spacer
  #range = { from: -1, to: -1 };
  #controller = new AbortController();
  #onScroll;

  constructor({ container, viewport, paint, key, rowHeight = 116, overscan = 5 }) {
    this.#container = container;
    this.#viewport = viewport;
    this.#paint = paint;
    this.#key = key;
    this.#rowHeight = rowHeight;
    this.#overscan = overscan;

    this.#topSpacer    = buildElement('li', { classes: 'spacer', 'aria-hidden': 'true' });
    this.#bottomSpacer = buildElement('li', { classes: 'spacer', 'aria-hidden': 'true' });

    // Visual work → one recalculation per frame, never more (section 13)
    this.#onScroll = perFrame(() => this.#sync());

    this.#viewport.addEventListener('scroll', this.#onScroll, {
      passive: true, signal: this.#controller.signal
    });
    window.addEventListener('resize', this.#onScroll, {
      passive: true, signal: this.#controller.signal
    });
  }

  /** Changes the list's data and repaints the window. */
  setData(data) {
    this.#data = data;
    this.#range = { from: -1, to: -1 };          // forces a full repaint
    this.#sync();
  }

  #sync() {
    // ── PHASE 1 · READ. Two reads, no writes in between ──
    const scrollTop = this.#viewport.scrollTop;
    const viewportHeight = this.#viewport.clientHeight;

    // ── Pure arithmetic, without touching the DOM ──
    const total = this.#data.length;
    const firstVisible = Math.floor(scrollTop / this.#rowHeight);
    const visibleCount = Math.ceil(viewportHeight / this.#rowHeight);

    const from = Math.max(0, firstVisible - this.#overscan);
    const to = Math.min(total, firstVisible + visibleCount + this.#overscan);

    if (from === this.#range.from && to === this.#range.to) return;  // nothing to do
    this.#range = { from, to };

    // ── PHASE 2 · WRITE ──
    this.#topSpacer.style.height = `${from * this.#rowHeight}px`;
    this.#bottomSpacer.style.height = `${Math.max(0, total - to) * this.#rowHeight}px`;

    const visible = this.#data.slice(from, to);
    const existing = new Map(
      [...this.#container.children]
        .filter((n) => !n.classList.contains('spacer'))
        .map((n) => [n.dataset.id, n])
    );

    const fragment = document.createDocumentFragment();
    for (const item of visible) {
      const k = String(this.#key(item));
      fragment.append(this.#paint(item, existing.get(k) ?? null));
      existing.delete(k);
    }

    // A single operation on the live tree: spacer + window + spacer
    this.#container.replaceChildren(this.#topSpacer, fragment, this.#bottomSpacer);

    // Whatever was left in `existing` is no longer in the window: it went away with the
    // replaceChildren, and since it is not stored anywhere, it is collectable garbage (09-03)
  }

  /** Nodes actually present, not counting the two spacers. */
  get painted() { return this.#container.children.length - 2; }

  destroy() {
    this.#controller.abort();
    this.#onScroll.cancel();
    this.#data = [];
    this.#container.replaceChildren();
  }
}

And the CSS that makes the arithmetic possible:

/* css/styles.css */
.column {
  height: 640px;
  overflow-y: auto;
  contain: layout paint;          /* the column is an island (section 12) */
}

.task-list { margin: 0; padding: 0; list-style: none; }

.task {
  box-sizing: border-box;
  height: 108px;                  /* FIXED HEIGHT: it is virtualization's contract */
  margin-bottom: 8px;             /* 108 + 8 = 116 px, VirtualList's `rowHeight` */
  overflow: hidden;
  contain: layout paint;
}

.spacer { padding: 0; margin: 0; }

And the wiring in the view, replacing the call to reconcile:

// js/view/board-view.js
import { VirtualList } from './virtual-list.js';
import { paintCard } from './card.js';

#lists = new Map();           // status → VirtualList

#prepareColumns() {
  this.#container.replaceChildren(...COLUMNS.map(({ status, title }) => {
    const column = $('#column-template').content.firstElementChild.cloneNode(true);
    // …exactly as in 06-06: dataset, title, aria-labelledby, aria-live…

    this.#lists.set(status, new VirtualList({
      container: $('.task-list', column),
      viewport: column,
      key: (task) => task.id,
      paint: (task, node) => paintCard(task, node, this.#state.today),
      rowHeight: 116,
      overscan: 5
    }));

    return column;
  }));
}

render() {
  const visible = this.#visible();
  const byStatus = Object.groupBy(visible, (t) => t.status);

  for (const { status } of COLUMNS) {
    const column = $(`.column[data-status="${status}"]`, this.#container);
    const inStatus = byStatus[status] ?? [];
    const hours = inStatus.reduce((sum, t) => sum + t.estimatedHours, 0);

    setText($('.column__count', column),
      `${inStatus.length} task${inStatus.length === 1 ? '' : 's'} · ${hours} h`);

    this.#lists.get(status).setData(inStatus);         // ← there is no reconcile here any more
    $('.column__empty', column).hidden = inStatus.length > 0;
  }

  // …summary (with the 09-02 cache) and event emission, exactly as in 06-06…
}

destroy() {
  for (const list of this.#lists.values()) list.destroy();
  this.#lists.clear();
  this.#controller.abort();
  // …the rest of the 09-03 cleanup…
}

The measurement, and this is where the lesson closes:

Measurement Without virtualizing With VirtualList
Full render(), 600 tasks 142 ms (78 with content-visibility) 31 ms
Cards in the DOM 600 48 (16 × 3 columns)
Document nodes 7,812 1,194
Heap memory 12.8 MB 6.1 MB
Repositioning while scrolling 1.7 ms per frame
Frames dropped in 5 s of scrolling 24 2

The 1,194 nodes come from a sum worth understanding: 612 nodes of the page shell (header, filters, form, summary, templates, the three columns with their titles and counters) + 48 cards × 12 nodes each = 576 + 6 spacers. Total, 1,194. Row 9 asked for ≤ 1,500.

And a property that is the most valuable of all: that number no longer depends on the size of the board. With 6,000 tasks it would be the same 1,194 nodes and the same 31 ms. Virtualization does not make the application 20% faster: it changes its complexity from O(n) to O(1) in the number of painted elements, which is exactly the kind of change 09-02 identified as the only one that really matters.

  1. The trade-offs of virtualizing

It would be dishonest to end the previous section without the small print. Virtualizing breaks things, and you need to know which and how they are mitigated.

What breaks Why Mitigation
Ctrl+F searching The browser only finds text that exists in the DOM The application's own search must be good; and content-visibility is searchable with hidden-matchable
Screen readers They announce "list of 16 items" instead of 600 role="list" + aria-setsize="600" and aria-posinset on each row
Keyboard tabbing You cannot tab to a row that does not exist Arrow-key navigation managed in code, and scrolling on focus
Printing Only the window is printed A "print everything" mode that disables virtualization
Links to a specific task The task may not be painted The router (07-06) must compute the scrollTop and scroll before looking for the node
Variable heights The arithmetic assumes equal rows Measure and cache heights, or force a fixed height with overflow: hidden
Code complexity 90 more lines and a new failure mode Dedicated tests and a correct destroy()

The first two deserve elaboration, because they are the most underestimated.

Accessibility. A badly built virtualized list is a serious accessibility regression. The bare minimum:

// In VirtualList, when painting each row
li.setAttribute('aria-setsize', String(this.#data.length));   // "of 600"
li.setAttribute('aria-posinset', String(from + i + 1));       // "number 137"
<ul class="task-list" role="list" aria-live="polite"></ul>

With that, a screen reader announces "task 137 of 600" even though there are only sixteen in the DOM. And the spacers carry aria-hidden="true" so they are not announced as empty list items.

The cheaper alternative. Before virtualizing, ask yourself whether content-visibility: auto is enough for you. The full comparison:

content-visibility: auto Virtualization
Effort 2 lines of CSS ~90 lines of JS and tests
render() 78 ms 31 ms
Nodes 7,812 1,194
Memory 12.8 MB 6.1 MB
Ctrl+F Works Broken
Screen readers Unchanged Requires aria-setsize
Variable heights No problem Requires extra work
Scales to 60,000 rows No (the tree gets heavy) Yes

Decision rule: use content-visibility: auto by default; virtualize only when the number of nodes is the problem, not the paint time. In Nómada Tasks we virtualize because row 9 of the baseline explicitly demands it and because the board will keep growing. If the baseline had only asked to go from 310 to 50 ms, content-visibility plus sections 7 and 10 would almost have been enough, with a tenth of the code.

And since we are being honest: the VirtualList implementation above is deliberately simple. It does not support variable heights, it does not handle smooth scrolling to a specific element, it does not restore focus when leaving and re-entering the window. Each of those is a good chunk of work. It is exactly the kind of problem you will see solved out of the box in Module 10.

  1. Event delegation, now with numbers

In 06-04 you learned delegation and it was justified on elegance: one handler on the container that works out the origin with closest('[data-action]'). In 09-03 it was justified on memory: one closure instead of 600. The time justification is missing, and with the virtualization from the previous section it becomes structural.

Measurement of the initial paint of 600 cards with three actions each:

One handler per button Delegation on .board
addEventListener calls 1,800 1
Cost of registering alone 38 ms 0.004 ms
Listener memory 1.2 MB ~0 kB
When a new card is created Its 3 have to be registered It just works
When a card is removed They have to be removed Nothing to remove
Cost per click 0.002 ms 0.014 ms (the closest)

The second-to-last row is the deep reason delegation is not optional when there is virtualization: cards enter and leave the DOM constantly as you scroll. With individual handlers you would have to register and remove listeners on every scroll frame, which would be as absurd as it sounds.

And on the last row: yes, each delegated click costs seven times more than a direct one. That is twelve microseconds, it happens once every few seconds, and in exchange you save 38 ms at startup and an entire family of leaks. It is exactly the kind of comparison 09-02 taught you to make: compare absolute magnitudes, not factors.

One final technical note. With the virtualized list, the delegated handler keeps working unchanged because closest walks up the tree and the .board container is never destroyed:

// js/view/controller.js — without a single change from 06-04
$('.board').addEventListener('click', (event) => {
  const button = event.target.closest('button[data-action]');
  if (button === null) return;

  const id = Number(button.closest('[data-id]').dataset.id);
  // …the rest unchanged…
}, { signal: controller.signal });

That a technique chosen for clarity is still correct after three lessons of optimization is no coincidence: it is the same observation 09-02 made about class Task. Good design decisions usually turn out to be the fast ones too.

  1. Rows 5 and 9, closed

Let us recap the full journey, because the sum is what gives the lesson its real measure.

Step What was done render() Nodes
Baseline (09-01) 310 ms 7,812
1 Read-then-write in #adjustHeights (section 7) 179 ms 7,812
2 Fingerprint and conditional writing in paintCard (section 10) 142 ms 7,812
3 contain: layout paint on .task and .column (section 12) 138 ms 7,812
4 content-visibility: auto (section 12) 78 ms 7,812
5 VirtualList (section 15) 31 ms 1,194

And the baseline rows now closed:

# Measurement Baseline Target After Status
5 Full render(), 600 tasks 310 ms ≤ 50 ms 31 ms
9 DOM nodes in the document 7,812 ≤ 1,500 1,194
2 INP when typing in the search box 480 ms → 96 ms (09-02) ≤ 200 ms 42 ms

Row 2 improves as a bonus: the debounce from 09-02 had left INP at 96 ms, of which 78 were the render. With the render at 31 ms, the complete interaction —keystroke, filtering, render, paint— drops to 42 ms, well below the 100 ms instant-response threshold from 09-01.

And two context figures that are not in the table but that matter:

  • Heap memory drops from 12.8 MB to 6.1 MB, because 6,618 nodes that no longer exist take up nothing. Again the 09-03 effect: fixing one thing improves another.
  • All of this is independent of the size of the board. With 6,000 tasks, render() still costs 31 ms and the document still has 1,194 nodes. The only thing that grows is the filtering and sorting, which are O(n) and O(n log n) over in-memory data: about 8 ms with 6,000 tasks.

  1. Symptom → likely cause → solution

This table is the lesson's operational summary. When something goes wrong in the DOM, start here.

Observed symptom What you see in DevTools Likely cause Solution
A short render becomes enormous at volume Many purple blocks interleaved with the yellow, with a warning triangle Layout thrashing Read-then-write (7)
"Forced reflow is a likely performance bottleneck" The literal warning A blacklist read inside a loop Read-then-write (7)
The render takes the same time even when nothing changes paintCard with a lot of self time Writing without checking Fingerprint and conditional writing (10)
An animation stutters Frames lane in red; purple every frame top, width or height is animated transform and opacity, or FLIP (11)
An animation freezes when you do something else A long yellow block during the animation The animation depends on the main thread CSS or Web Animations instead of rAF (11)
Scrolling is jerky scroll handler with a lot of self time scroll unthrottled or with a fixed throttle perFrame with rAF (13)
Everything slows down as the list grows Enormous Layout and Paint on the initial render Elements nobody sees are being painted content-visibility (12) or virtualization (15)
The node count goes up and never down Rising Nodes curve in Performance Nodes accumulating (or a leak) Virtualization (15) or revisit 09-03
Changing one card repositions the whole page Layout affecting many nodes No isolation contain: layout paint (12)
Inserting elements is slow Many Layout entries in the insertion loop An interleaved geometry read Build outside the tree (8)
The first paint takes ages but then it is fine One huge block at startup Everything is painted at once Progressive list (14) or virtualization (15)
The scrollbar jumps while scrolling content-visibility with no intrinsic size contain-intrinsic-size: auto Xpx (12)
The application uses a lot of video memory Many layers in the Layers panel will-change applied to too many elements Apply it and remove it (12)

Common Mistakes and Tips

  • Believing "the DOM is slow". The DOM is not slow: recomputing style and layout is, and only when you force it out of turn. Crossing the JS↔DOM boundary costs nanoseconds.
  • Reading geometry inside a loop that writes. This is the mistake of the lesson. Forty reads of offsetHeight cost 137 ms; the same forty, grouped, cost 4.3 ms.
  • Not recognizing getComputedStyle as an expensive read. It is the most treacherous item on the blacklist, because it does not look like a measurement.
  • Using innerText out of habit. It forces a recalculation; textContent does not. Unless you specifically need the visible text, use textContent.
  • Calling focus() in the middle of a loop of writes. It is a disguised read: it forces layout in order to know where the element is.
  • Believing DocumentFragment is the great optimization. It contributes 9%. What contributes 1,600% is not reading geometry inside the loop.
  • Always writing, without checking. Assigning the same textContent that was already there invalidates all the same. A cheap fingerprint avoids 9,600 writes per render.
  • Animating top, left, width or height. Every frame goes through layout. Use transform and opacity, and FLIP when the change is a real positional one.
  • Putting will-change on everything just in case. Every promoted element consumes GPU memory. Enable it just before and remove it when done.
  • Using contain: size without giving dimensions. The element is treated as zero-sized and disappears.
  • Using content-visibility: auto without contain-intrinsic-size. The scrollbar goes haywire. With auto Xpx, the browser remembers the real size.
  • Using setTimeout for visual work. It does not sync with the screen, it keeps running in hidden tabs and it produces double frames. Use requestAnimationFrame.
  • Using throttle(fn, 100) on scroll. Better than nothing, worse than requestAnimationFrame: it runs fewer times and drops more frames.
  • Forgetting { passive: true } on scroll, wheel and touchstart. The browser has to wait to see whether you call preventDefault.
  • Virtualizing without fixing accessibility. Without aria-setsize and aria-posinset, a list of 600 is announced as one of 16. That is a serious regression.
  • Virtualizing when content-visibility was enough. Two lines of CSS against ninety of JavaScript with a new failure mode. Measure before deciding.
  • Virtualizing with variable heights without measuring them. The arithmetic assumes equal rows; if they are not, the scrollbar lies.
  • Tip: measure the node count, not just the time. document.getElementsByTagName('*').length in the console, or the Nodes curve in the Performance panel. It is an early indicator that time does not give you.
  • Tip: look for the warning triangle in the flame chart. DevTools literally tells you where the forced recalculation is, along with the call stack that caused it.
  • Tip: use the DevTools Rendering panel. Paint flashing colors in green whatever gets repainted —if the whole screen flashes when you change one card, you have an isolation problem— and Layout Shift Regions highlights content jumps, which will be the stars of 09-05.
  • Tip: write a test that counts writes. In Jest with jsdom there is no layout, but you can spy on Element.prototype.setAttribute and assert that a render with no changes writes nothing. It is cheap and it protects section 10.
  • Tip: the virtual list's spacers must carry aria-hidden="true". Otherwise they are announced as empty list items.

Exercises

Exercise 1 — Hunting the thrashing. This code places a progress bar in each column of the board, proportional to the open hours. With the three columns and 600 tasks it takes 96 ms, and DevTools flags the forced-recalculation warning.

function paintBars(columns) {
  for (const column of columns) {
    const bar = column.querySelector('.column__bar');
    const width = column.clientWidth;                          // (a)
    const height = getComputedStyle(bar).height;               // (b)

    bar.style.width = `${width * 0.8}px`;                      // (c)
    bar.classList.toggle('column__bar--tall', width > 320);    // (d)

    const label = column.querySelector('.column__count');
    label.style.top = `${bar.getBoundingClientRect().bottom + 4}px`;   // (e)
    label.textContent = `${Math.round(parseFloat(height))} px of bar`; // (f)
  }
}

Answer: (1) which lines are blacklist reads and which are writes; (2) how many forced recalculations it causes with three columns and why; (3) rewrite it with read-then-write; (4) estimate the improvement knowing that a forced recalculation over this tree costs about 11 ms; and (5) point out a second improvement, independent of the ordering, that reduces the cost even further.

Exercise 2 — content-visibility or virtualization? For each of these four Nómada Tasks screens, choose between "do nothing", "content-visibility: auto" and "virtualize", justifying it with the trade-offs from section 16.

  1. Taller Nómada's canonical board: 6 tasks in three columns.
  2. The change history: 4,200 lines of text of a single height, which Marta searches with Ctrl+F constantly.
  3. The print view of the quarterly report: 600 rows that have to be sent to the printer all at once.
  4. The tag picker: 6 elements with variable heights depending on the length of the name.

Exercise 3 — Animating the column change. When Iván presses "Start", the card jumps from the "Not started" column to "Under way" with no transition. Write the correct animation: (a) explain why it cannot be solved with a CSS animation of top and left; (b) implement the movement with the flip function from section 11, integrated into the state → render → event flow from 06-06; (c) state how to avoid layout thrashing if several cards change at once; and (d) add respect for prefers-reduced-motion and explain why the animation must keep working even if the main thread is busy.

Solutions

Solution 1

(1) Classification of the lines:

Line Type Reason
(a) column.clientWidth Read Client geometry
(b) getComputedStyle(bar).height Read Resolved style, and it depends on layout
(c) bar.style.width = … Write Invalidates style and layout
(d) classList.toggle(...) Write Invalidates style (and layout, because the class changes the size)
(e) bar.getBoundingClientRect() Read inside a write block Rectangle
(f) label.textContent = … Write Invalidates style, layout and paint

(2) Forced recalculations. On each pass there are two read→write→read blocks: (a)(b) read, (c)(d) invalidate, (e) reads again —forced—, (f) invalidates once more. On the next pass, (a) reads with invalidations pending —forced— and (b) can take advantage of the recalculation just performed. With three columns: the first pass does 1 natural recalculation + 1 forced one at (e); passes 2 and 3 do 2 forced ones each. Total: 5 forced recalculations and about 55 ms on that alone. The structure is the one from section 6: the loop alternates.

(3) Rewrite:

function paintBars(columns) {
  // ── PHASE 1 · READ EVERYTHING. A single forced recalculation, at the start ──
  const measurements = columns.map((column) => {
    const bar = column.querySelector('.column__bar');
    return {
      column,
      bar,
      label: column.querySelector('.column__count'),
      width: column.clientWidth,
      barHeight: parseFloat(getComputedStyle(bar).height)
    };
  });

  // ── PHASE 2 · WRITE EVERYTHING. No reads in here ──
  for (const { bar, label, width, barHeight } of measurements) {
    bar.style.width = `${width * 0.8}px`;
    bar.classList.toggle('column__bar--tall', width > 320);

    // The label's position is COMPUTED, not read back from the DOM
    label.style.top = `${barHeight + 4}px`;
    label.textContent = `${Math.round(barHeight)} px of bar`;
  }
}

The key change, beyond separating the phases, is that line (e) disappears: the bar's bottom position is derived from the height already measured instead of asking the DOM again. The best geometry read is the one you never do.

(4) Estimated improvement. From 5 forced recalculations to 1: you save 4 × 11 ms = 44 ms, and the total time goes from 96 ms to about 52 ms. Measured on the reference laptop at CPU 4×, the real result was 96 → 49 ms, slightly better than estimated because the remaining recalculation works over a less invalidated tree.

(5) A second improvement, independent of the ordering. Replace the inline styles with CSS variables and let the CSS do the computation. That way you write a single custom property per column and the browser resolves widths and positions without JavaScript measuring anything:

column.style.setProperty('--ratio', String(open / total));
.column__bar { width: calc(var(--ratio, 0) * 80%); }
.column__count { top: calc(var(--bar-height, 24px) + 4px); }

With this there is not a single geometry read left, and the cost drops to 1.2 ms. It is 06-02's underlying lesson taken to its extreme: let CSS do the geometry.

Solution 2

# Screen Decision Justification
1 Canonical board, 6 tasks Do nothing 78 nodes and a 1.4 ms render. Any technique from this lesson would add complexity with no measurable benefit. It is the literal application of 09-01's rule: if you have not measured it as a problem, do not optimize it
2 4,200-line history with Ctrl+F content-visibility: auto Virtualizing would break Ctrl+F, which is the main use case of that screen. Since the lines have a uniform height, contain-intrinsic-size: auto 28px keeps the scrolling perfect, and the layout and paint saving is practically the same. The 4,200 nodes are acceptable as long as they do not grow without limit
3 Print view, 600 rows Do nothing (and turn off any optimization) Printing needs all the content in the DOM. If the board is virtualized, you have to unvirtualize before printing, by listening to window.matchMedia('print') or the beforeprint event. It is the trade-off from section 16's table
4 Picker of 6 tags, variable height Do nothing Six elements. And even if there were six hundred, the variable height breaks VirtualList's arithmetic, which requires equal rows; you would have to measure and cache heights, at which point content-visibility would be the sensible option

The rule running through all four answers: the right technique depends on how the screen is used, not on how many elements it has. The 4,200 history lines and the 600 report rows have similar size problems and opposite solutions, because in one Ctrl+F rules and in the other the printer does.

Solution 3

(a) Why animating top and left is no good. Two reasons, and the second is the one that rules the solution out:

  1. Both are in the red row from section 11: every frame goes through style, layout, paint and composite. With 24 cards moving, 38 of 58 frames are dropped.
  2. And above all: the card does not move within a container, it changes container. It goes from the "Not started" <ul> to the "Under way" <ul>. A CSS animation cannot interpolate between two positions in different trees, because the change of parent is instantaneous and the initial state stops existing. That is why FLIP is needed: measure first, apply the real change, measure afterwards, and animate the difference with transform.

(b) Implementation integrated into the 06-06 flow:

// js/view/controller.js
import { flip } from './animate.js';
import { $ } from './dom.js';

$('.board').addEventListener('click', (event) => {
  const button = event.target.closest('button[data-action]');
  if (button === null) return;

  const li = button.closest('[data-id]');
  const id = Number(li.dataset.id);
  const task = board.findById(id);
  const destination = nextStatus(task, button.dataset.action);
  if (destination === null) return;

  // FLIP wraps the whole cycle: the state change AND the render
  li.style.willChange = 'transform';
  const animation = flip(li, () => {
    board.changeStatus(id, destination);   // 1 · change the STATE
    view.render();                         // 2 · redraw: the <li> changes column
  });

  animation?.finished.finally(() => { li.style.willChange = 'auto'; });
}, { signal: controller.signal });

This works because the data-id reconciliation from 06-06 reuses the same node: insertBefore moves it to the new <ul> instead of destroying it and creating another (06-05). If the render recreated the card, the node measured in the "First" phase would no longer exist and FLIP would have nothing to animate. It is a lovely case where a correctness decision taken three modules earlier makes a visual optimization possible.

(c) Several cards at once. The mistake would be to call flip in a loop: each call does two getBoundingClientRects, so twenty-four cards would be 48 reads alternating with 24 layout changes. The correct version groups the three phases:

export function flipMany(nodes, change) {
  // F · read all the initial positions: ONE recalculation
  const firsts = new Map(nodes.map((n) => [n, n.getBoundingClientRect()]));

  change();                                    // the real change, in one go

  // L · read all the final ones: ONE recalculation
  const lasts = new Map(nodes.map((n) => [n, n.getBoundingClientRect()]));

  // I + P · animate: writes only, composite only
  return nodes.map((n) => {
    const a = firsts.get(n);
    const b = lasts.get(n);
    const dx = a.left - b.left;
    const dy = a.top - b.top;
    if (dx === 0 && dy === 0) return null;
    return n.animate(
      [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
      { duration: 220, easing: 'cubic-bezier(0.2, 0, 0.2, 1)' }
    );
  }).filter(Boolean);
}

Two forced recalculations in total, instead of 48. It is section 7's pattern applied to animation.

(d) prefers-reduced-motion and why the animation survives a busy thread:

const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

export function flip(node, change) {
  if (reducedMotion.matches) { change(); return null; }   // ← accessibility first
  // …the rest unchanged…
}

Respecting prefers-reduced-motion (07-06) is not an aesthetic detail: for people with vestibular disorders, motion on screen can cause genuine nausea. The animation must disappear, not slow down.

And the reason this animation survives a busy main thread is the one from section 11: element.animate() with transform and opacity runs on the compositor thread, not the main one. Once launched, if your JavaScript spends 300 ms doing something, the animation keeps running at 60 fps. With requestAnimationFrame it does not: every frame depends on the main thread being free, so the animation would freeze. It is a decisive argument for preferring CSS or the Web Animations API whenever the effect can be expressed declaratively.

Conclusion

The two rows that dominated the table are closed. render() has gone from 310 ms to 31 ms and the document from 7,812 nodes to 1,194, with the search box's INP dropping as a bonus from 96 to 42 ms and the heap from 12.8 to 6.1 MB. And most valuable of all: those figures no longer depend on the size of the board.

You understand why the DOM is expensive, and it is not for the reason almost everybody thinks. Crossing the boundary between JavaScript and the rendering engine costs nanoseconds; what costs milliseconds is recomputing, and the browser already avoids recomputing more than necessary by batching all your writes until just before the next frame. You know the rendering pipeline —JavaScript, style, layout, paint, composite— and you know you can enter it halfway through: changing width runs all five phases, changing background-color skips layout, and changing transform or opacity skips paint as well and is resolved by the GPU on its own.

You know what a forced synchronous layout is and you have the blacklist that causes it —offsetHeight and family, clientWidth, scrollTop, getBoundingClientRect, getComputedStyle, innerText, focus(), scrollIntoView()— which closes the warning 06-02 left open. And you recognize layout thrashing at a glance: one of those reads inside a loop that also writes. In Nómada Tasks it was forty reads of offsetHeight over the overdue cards —the 41 style recalculations and 38 layouts from 09-01's Bottom-Up— and they cost 137 ms of the 310. The read-then-write pattern reduced them to one and the render to 179 ms, without changing a single operation: only the order.

You have the real magnitudes of each technique, which is what stops you wasting time on the wrong one. DocumentFragment and replaceChildren contribute 9–10%, not the order of magnitude the legend attributes to them; what multiplies by sixteen is interleaving a read in the insertion loop. reconcile from 06-06 wins 38× in the frequent case —one card changing— and loses to replaceChildren when the whole list is reordered, a limit the lesson itself announced. And the dullest optimization turned out to be one of the best: not writing what has not changed, with a cheap fingerprint that saves 9,600 writes per render and brings paintCard down from 149 to 101 ms.

You know how to animate cheaply: only transform and opacity, because they are the only ones resolved in compositing; with FLIP for movements that genuinely change geometry, with will-change added and removed rather than left permanent, and with the decisive observation that a CSS or Web Animations API animation survives a busy main thread while a requestAnimationFrame one does not. You know the three isolation tools: contain to promise that what is inside does not affect what is outside, content-visibility: auto with its mandatory contain-intrinsic-size —two lines of CSS that brought the render down from 142 to 78 ms—, and will-change with its cost in GPU memory. And you know when requestAnimationFrame is called for instead of setTimeout: for all visual work, with the perFrame pattern that runs more often than a 100 ms throttle and still drops twelve times fewer frames, because what matters is not running rarely but running at the right moment.

You have implemented virtualization twice: the progressive list with IntersectionObserver sentinels, which makes the first card appear in 11 ms but accumulates nodes as you scroll, and the computed window with fixed heights and spacers, which keeps 48 cards in the DOM whatever the size of the board. And you have seen its small print unadorned: it breaks Ctrl+F, it demands aria-setsize and aria-posinset so as not to wreck the screen-reader experience, it complicates printing and deep links, it requires rows of uniform height and it adds ninety lines with a new failure mode. Hence the decision rule: content-visibility by default, virtualization only when the nodes are the problem. Here they were, because row 9 explicitly demanded it. Finally, the event delegation from 06-04 has received its third justification —38 ms of registration against 0.004 ms, and the only sensible way of coexisting with cards that enter and leave the DOM on every frame— confirming once again that good design decisions usually turn out to be the fast ones too.

One front remains, and it is the only one you cannot fix by writing better JavaScript, because it happens before the first line of your code runs. Rows 1, 3 and 10 are still untouched: 4.1 s of LCP, 0.21 of CLS and 214 kB in 28 requests before the first card appears. Everything you have optimized across three lessons —the Map index, the cache, the worker, the leak cleanup, the virtual window— is worth nothing during the four seconds in which Lucía stares at a blank screen on her phone. Something else rules there: what the browser downloads, in what order and how much of it is actually used. It is the debt 01-03 and 06-01 noted when talking about defer and type="module", and the one 05-04 explicitly postponed when mentioning tree shaking and bundlers. Its turn has come, and it closes the module: Lazy Loading and Code Splitting.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved