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
- Why the DOM is expensive
- The rendering pipeline, phase by phase
- Which operation wakes up which phase
- Reflow, repaint and forced synchronous layout
- The blacklist: which reads force a reflow
- Layout thrashing: the loop that alternates reading and writing
- The read-then-write pattern, measured over the 600 cards
- Grouping insertions:
DocumentFragmentandreplaceChildren - Updating instead of redrawing: what
reconcilereally contributes - Writing only what changes
- Cheap animation:
transformandopacity - Isolating the work:
will-change,containandcontent-visibility - Syncing with
requestAnimationFrame(and not withsetTimeout) - Virtualization I: sentinels with
IntersectionObserver - Virtualization II: the computed window with fixed heights
- The trade-offs of virtualizing
- Event delegation, now with numbers
- Rows 5 and 9, closed
- Symptom → likely cause → solution
- Common Mistakes and Tips
- Exercises
- Conclusion
- 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.
- 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.
- 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__metaon 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 forDocumentFragment(06-05). display: noneandvisibility: hiddenare 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.
- 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, synchronousDevTools 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.
- 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.
- 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.
- 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.
- Grouping insertions:
DocumentFragment and replaceChildren
DocumentFragment and replaceChildrenIn 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 | 1× |
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.
replaceChildrencannot.
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 |
- Updating instead of redrawing: what
reconcile really contributes
reconcile really contributesIn 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:
reconcilewins 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.
- 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 3classList.add/toggle - 3
textContentassignments - 1
replaceChildrenof the tag list, which destroys and recreates the tag<li>s - 2 writes of
disabledand 2setAttribute('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.
disabledis 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,datasetwith associated CSS selectors— not everything as a matter of course.classList.addof an already present class does not invalidate either.classListis aDOMTokenListand it checks membership before modifying. That is the reason 06-02 insisted on usingclassListrather than hammeringclassName: 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.
- Cheap animation:
transform and opacity
transform and opacityLet 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.
- Isolating the work:
will-change, contain and content-visibility
will-change, contain and content-visibilityThese 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.
And now the three rules that make will-change help rather than hurt:
- Use it with extreme moderation. Every layer consumes GPU memory. Putting
will-change: transformon all 600 cards can consume hundreds of megabytes of video memory and make the application slower. It is the most frequent misuse. - Add it and remove it. The right approach is to add it just before (with a class, or on
:hoverover the ancestor) and remove it when done. Leaving it permanently in the CSS is exactly what you must not do. - Do not use it to "fix" slow animations. If your animation touches
height,will-change: heightis 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 |
- Syncing with
requestAnimationFrame (and not with setTimeout)
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:
requestAnimationFramefor visual work,setTimeout/debouncefor 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.
- Virtualization I: sentinels with
IntersectionObserver
IntersectionObserverWe 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+removewhen the data runs out stops the observer from staying alive for no reason, and avoids the detached node from 09-03.destroy()withdisconnect()is mandatory: anIntersectionObserverobserving 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.
- 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.
- 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"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: autoby 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-visibilityplus 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.
- 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.
- 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.
- 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
offsetHeightcost 137 ms; the same forty, grouped, cost 4.3 ms. - Not recognizing
getComputedStyleas an expensive read. It is the most treacherous item on the blacklist, because it does not look like a measurement. - Using
innerTextout of habit. It forces a recalculation;textContentdoes not. Unless you specifically need the visible text, usetextContent. - 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
DocumentFragmentis the great optimization. It contributes 9%. What contributes 1,600% is not reading geometry inside the loop. - Always writing, without checking. Assigning the same
textContentthat was already there invalidates all the same. A cheap fingerprint avoids 9,600 writes per render. - Animating
top,left,widthorheight. Every frame goes through layout. Usetransformandopacity, and FLIP when the change is a real positional one. - Putting
will-changeon everything just in case. Every promoted element consumes GPU memory. Enable it just before and remove it when done. - Using
contain: sizewithout giving dimensions. The element is treated as zero-sized and disappears. - Using
content-visibility: autowithoutcontain-intrinsic-size. The scrollbar goes haywire. Withauto Xpx, the browser remembers the real size. - Using
setTimeoutfor visual work. It does not sync with the screen, it keeps running in hidden tabs and it produces double frames. UserequestAnimationFrame. - Using
throttle(fn, 100)onscroll. Better than nothing, worse thanrequestAnimationFrame: it runs fewer times and drops more frames. - Forgetting
{ passive: true }onscroll,wheelandtouchstart. The browser has to wait to see whether you callpreventDefault. - Virtualizing without fixing accessibility. Without
aria-setsizeandaria-posinset, a list of 600 is announced as one of 16. That is a serious regression. - Virtualizing when
content-visibilitywas 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('*').lengthin 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.setAttributeand 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.
- Taller Nómada's canonical board: 6 tasks in three columns.
- The change history: 4,200 lines of text of a single height, which Marta searches with Ctrl+F constantly.
- The print view of the quarterly report: 600 rows that have to be sent to the printer all at once.
- 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__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:
- 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.
- 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 withtransform.
(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
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
