Nómada Tasks works and is tested: 124 tests in four seconds and three E2E journeys confirm that it does what it should. But Taller Nómada has been using it for two years and the board no longer holds six tasks: it holds six hundred. Marta says it "feels slow". Iván says it "jams when you type in the search box". Lucía says it "takes ages to load". Three sentences, three possibly different problems, and not one of them actionable. Before touching a single line you have to turn "it feels slow" into a number, that number into a target, and that target into a test that fails when the target is missed. This lesson teaches that discipline: what "fast" means to a human being, which metrics capture it (the Web Vitals), which tools produce them (Lighthouse, the Performance panel, the Network panel, the User Timing API), how to take an honest measurement that is not fooled by noise or by the developer's machine, and how to set a performance budget that continuous integration polices on its own. By the end you will have the baseline for Nómada Tasks with 600 tasks: the table of numbers that the next four lessons are going to improve.

Contents

  1. Why intuition fails (and what Knuth actually said)
  2. What a person perceives: 100 ms, 1 s and 16.7 ms
  3. The Web Vitals, one by one
  4. Lab data and field data
  5. Lighthouse: reading the report without obsessing over the score
  6. The Performance panel, step by step
  7. The Network panel and network and CPU throttling
  8. Instrumenting your own code: performance.now() and the User Timing API
  9. Measuring in production: PerformanceObserver and web-vitals
  10. How to run an honest benchmark
  11. Your machine lies: simulating a modest phone
  12. The baseline for Nómada Tasks with 600 tasks
  13. The performance budget and continuous integration
  14. Common Mistakes and Tips
  15. Exercises
  16. Conclusion

  1. Why intuition fails (and what Knuth actually said)

In 08-01 you learned that debugging by intuition does not converge: you change things, some of them appear to work, and you end up with worse code and a bug that is still there. Performance is exactly the same, only worse, because at least a bug shows itself while slowness can be denied ("works fine for me").

There is one sentence that gets quoted constantly and almost always wrongly:

"Premature optimization is the root of all evil."

The line is Donald Knuth's, from Structured Programming with go to Statements (1974), and in full it says something rather different:

"We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%."

And a few lines earlier, the paragraph nobody quotes and the one that actually matters here:

"It is often a mistake to make a priori judgments about what parts of a program are really critical, since the universal experience of programmers who have been using measurement tools has been that their intuitive guesses fail."

Knuth was not saying "don't optimize". He was saying "don't optimize what you haven't measured", which is precisely the opposite of how the quotation is normally used. His explicit recommendation was to instrument the program, find the 3% that consumes the time and work there with all the care in the world.

Why does intuition fail? For three concrete reasons:

Reason Example in Nómada Tasks
The cost is not where it looks The sort of 600 tasks costs 0.4 ms; painting their 600 cards costs 310 ms. Everybody stares at the sort
Orders of magnitude deceive Swapping forEach for for saves microseconds; taking a getBoundingClientRect out of a loop saves 400 ms
The bottleneck moves Fix the render and the problem becomes the download; optimizing the render again then adds nothing

The practical consequence is a working rule you will apply throughout the module:

flowchart LR
    A["1 · Define<br/>what fast means"] --> B["2 · Measure<br/>the baseline"]
    B --> C["3 · Find<br/>the bottleneck"]
    C --> D["4 · Change<br/>ONE thing"]
    D --> E["5 · Measure<br/>again"]
    E -->|"improved"| F["6 · Set<br/>the budget"]
    E -->|"no improvement"| G["Revert"]
    G --> C
    F --> C

Step 4 —change one single thing— is the one almost everybody skips, and it is the same rule as in debugging: if you change five things and it improves, you do not know which one helped, and four of them have probably just added complexity. The Revert step is not optional either: an optimization that does not improve anything is pure technical debt.

  1. What a person perceives: 100 ms, 1 s and 16.7 ms

"Fast" is not an absolute number: it is whatever the human perceptual system tolerates. There are three thresholds worth memorizing because they govern every decision in this module.

Threshold What it means What happens if you exceed it
~100 ms The limit of feeling an instant response to your own action The user perceives that the application "reacts" rather than "responds"
~1 s The limit for keeping the train of thought The wait is noticeable, but the mental task is not abandoned
~10 s The limit of attention The user switches tabs, and you must show explicit progress
16.7 ms The budget for one frame at 60 Hz The frame is dropped: the animation or the scroll "stutters"

The 16.7 ms one deserves an explanation. A 60 Hz screen refreshes 60 times per second: 1000 / 60 = 16.7 ms per frame. In that time the browser has to run your JavaScript, recalculate styles, do layout, paint and composite. If your scroll handler takes 20 ms, the frame does not arrive in time and is dropped. And on 120 Hz screens the budget drops to 8.3 ms.

Since the browser needs part of that time for its own work, the rule of thumb is to leave under 10 ms of JavaScript per frame whenever something is moving.

flowchart TD
    subgraph F["One frame at 60 Hz: 16.7 ms"]
        direction LR
        J["JS<br/>~10 ms"] --> S["Style"] --> L["Layout"] --> P["Paint"] --> C["Composite"]
    end
    F --> OK["Frame delivered on time"]
    X["20 ms of JS"] --> KO["Dropped frame:<br/>visible stutter"]

Translated to Nómada Tasks, this already gives three concrete targets before measuring anything at all:

  • Pressing "Start" on a card must be visibly reflected in under 100 ms.
  • The board must be visible in under 1 s on a decent connection.
  • Scrolling the not-started column must not spend more than 10 ms of JavaScript per frame.

Notice the change of language: we have moved from "it feels slow" to three falsifiable statements. That is already half the work.

  1. The Web Vitals, one by one

The thresholds in the previous section are psychology. The Web Vitals are the standardized way of measuring them on a real page: a small set of metrics defined by Google that capture loading, interactivity and visual stability. They are what Lighthouse, PageSpeed Insights, Search Console and practically every monitoring tool measure.

There are three main ones (the Core Web Vitals) and two supporting ones that serve for diagnosis.

Metric What it measures Good Needs improvement Poor What typically makes it worse
LCP (Largest Contentful Paint) When the largest content element in the viewport is painted ≤ 2.5 s 2.5–4 s > 4 s Slow server, render-blocking CSS and JS, heavy or unprioritized images, fonts
INP (Interaction to Next Paint) Interaction latency: from the press to the next paint ≤ 200 ms 200–500 ms > 500 ms Heavy handlers, long tasks, a full render on every event
CLS (Cumulative Layout Shift) How much the content moves without the user causing it ≤ 0.1 0.1–0.25 > 0.25 Images without dimensions, fonts that change size, banners inserted late
TTFB (Time to First Byte) When the first byte of the document arrives ≤ 800 ms 0.8–1.8 s > 1.8 s Server, database, redirects, missing cache or CDN
FCP (First Contentful Paint) When the first content of any kind appears ≤ 1.8 s 1.8–3 s > 3 s Render-blocking resources, CSS above all

Four clarifications that head off most of the misunderstandings:

LCP is not "when the page loads". It is when the largest element in the viewport appears: normally the hero image, a large block of text or —in Nómada Tasks— the .board container with the first cards. If your application paints a gray skeleton blazingly fast and the real cards take three seconds, LCP will be poor, and rightly so: a skeleton is not content.

INP replaced FID. The old metric, First Input Delay, measured only the delay before the handler for the first interaction started running; it was far too lenient. INP measures every interaction in the session, end to end —input delay, handler execution and painting of the result— and keeps (roughly) the worst one. It is the metric Nómada Tasks is going to fail: typing in the search box triggers a full render of 600 cards.

CLS has no unit. It is a product of the fraction of the screen affected and the distance travelled, accumulated over session windows. A 0.21 means "the content jumps in a clearly noticeable way". In Nómada Tasks the culprit is predictable: the cards are inserted when the data arrives and push down whatever was already there.

TTFB and FCP are not targets, they are diagnostics. You do not optimize them for their own sake: you look at them to find out where the LCP problem is. If TTFB is 1.4 s of the 4.1 s of LCP, the problem belongs to the server and you are not going to fix it by touching JavaScript.

flowchart LR
    A["Request"] -->|"TTFB"| B["First byte"]
    B -->|"parse HTML,<br/>blocking CSS"| C["FCP<br/>first content"]
    C -->|"JS, data,<br/>images"| D["LCP<br/>main content"]
    D --> E["User<br/>interaction"]
    E -->|"INP"| F["Next paint"]

One important warning: the Web Vitals measure the experience, not the quality of your code. A page with an LCP of 1.2 s can have horrible JavaScript, and an impeccable page can fail because of a font. They are the first question, not the last one.

  1. Lab data and field data

You are going to see the same metric twice with completely different values, and it is worth understanding why before you drive yourself mad.

Lab (lab data) Field (field data / RUM)
How you get it You run Lighthouse or the Performance panel on your machine You collect metrics from real users in production
Environment Controlled, repeatable, simulated Chaotic: old phones, 3G, background tabs, extensions
Advantage Reproducible; can go in CI; allows A/B comparison It is the truth: what actually happens to people
Drawback It is not reality Not reproducible; arrives late; needs volume
INP It is estimated; nobody really interacts It is genuinely measured
Summarized by One run (or the median of several) The 75th percentile of all sessions

The use of the 75th percentile is the key to why the two never match. When Search Console says your LCP is 3.8 s, it is not saying the average user waits 3.8 s: it is saying that 25% of visits wait longer than that. Your local Lighthouse can report 1.9 s without lying; you are simply measuring the percentile of a user with fiber, a powerful laptop and a warm cache.

The operating rule:

  • Field to find out whether there is a problem and who it happens to.
  • Lab to find the cause and to verify that a change fixes it.

Using lab data alone leads to optimizing problems nobody has. Using field data alone leads to knowing something is wrong without being able to work out what.

  1. Lighthouse: reading the report without obsessing over the score

Lighthouse is built into DevTools (the Lighthouse tab), available on the command line (npx lighthouse) and inside PageSpeed Insights. It produces a lab report with five categories; here only Performance interests us.

# Report in the terminal, useful for continuous integration
npx lighthouse http://localhost:5173 \
  --preset=desktop \
  --output=json --output-path=./reports/lh-desktop.json

# Mobile profile (the one that matters): CPU 4× slower and simulated slow network
npx lighthouse http://localhost:5173 \
  --output=html --output-path=./reports/lh-mobile.html

By default Lighthouse simulates a mid-range phone: it throttles the CPU to 4× slower than yours and applies a slow network. That explains why the mobile score always comes out far worse than the desktop one, and why mobile is the one you have to look at.

Now, the important part: how to read the report.

  1. Ignore the big number. The 0-to-100 score is a weighted average of the metrics, compressed onto a log-normal curve. Going from 50 to 60 may be an enormous change or measurement noise; and chasing 100 leads to absurd decisions. What matters are the individual metrics.
  2. Read the metrics at the top, with their colors. That is where FCP, LCP, TBT, CLS and Speed Index live.
  3. TBT (Total Blocking Time) is your lab stand-in for INP. It adds up, across all long tasks, the milliseconds beyond 50 ms. It is the only thing Lighthouse can tell you about interactivity without a user interacting. If TBT is high, field INP is going to be bad.
  4. Scroll down to Diagnostics and the opportunities. That is where the concrete actions are, with their estimated saving. Treat them as clues, not orders: the "estimated saving" is a model, not a measurement.
  5. Look for the LCP element. The report points it out explicitly. Knowing which element it is changes the diagnosis completely.
  6. Run it three times. Lighthouse has noticeable variance. A single run is not data.
  7. Run it in incognito mode with no extensions. Extensions inject scripts and skew the result; it is the most common measurement error there is.

A real extract from the Nómada Tasks report with 600 tasks (mobile profile, three runs, median):

Lighthouse metric Value Verdict
First Contentful Paint 2.3 s Needs improvement
Largest Contentful Paint 4.1 s Poor
Total Blocking Time 1,240 ms Poor
Cumulative Layout Shift 0.21 Needs improvement
Speed Index 3.9 s Needs improvement
Score 38 (meaningless on its own)

That TBT of 1,240 ms is the strongest signal in the report: there is more than a second during which the main thread is busy and the interface does not respond. Lighthouse does not tell you why. That is what the next panel is for.

  1. The Performance panel, step by step

The DevTools Performance panel is the central tool of this module. It records everything the browser does over an interval and presents it on a timeline. It is intimidating the first time; with a fixed procedure it stops being so.

Recording procedure

  1. Open the application in an incognito window (no extensions).
  2. Open DevTools → Performance tab.
  3. Tick Screenshots and turn on throttling: CPU: 4× slowdown and, if loading is what interests you, Network: Slow 4G.
  4. To measure loading: press the reload-with-a-circle button (Start profiling and reload page). To measure an interaction: press the record circle, perform the interaction, and stop as soon as it finishes.
  5. Record little. Three seconds of recording is analyzable; thirty is not.

How to read the recording, from top to bottom:

Area What it holds What you use it for
Screenshots Real frames Seeing when the content appears
Web Vitals FCP, LCP, DCL and Load markers Anchoring the metrics on the timeline
Frames One rectangle per frame; red = dropped Diagnosing scrolling and animations
Main The main thread: the flame chart Where 90% of the answers are
Network Request waterfall Seeing what blocks what
Summary / Bottom-Up / Call Tree Aggregates for the selection Quantifying

The flame chart in the Main lane is read like this: the horizontal axis is time; each bar is a function call; a bar beneath another is a function called by the one above. The width is total time (self + descendants). The colors have fixed meanings:

Color Category
Yellow Scripting (your JavaScript)
Purple Rendering (style calculation and layout)
Green Painting (paint and composite)
Blue Loading (parsing HTML and CSS)
Gray Other / system

Long tasks are the first thing to look for. A task is a unit of work that the event loop (05-07) runs without interruption; if it lasts more than 50 ms it counts as long and DevTools marks it with a red triangle in the corner and a red hatched band. While it lasts, the browser cannot service clicks or paint. That is the exact visual translation of what you studied in 05-07: a heavy loop freezes the interface, and await does not fix it.

Bottom-Up versus Call Tree. Select an interval on the timeline and look at the tabs below:

Tab Groups by Answers
Call Tree Call chain from the root "What caused this work?"
Bottom-Up Leaf function, summing all its appearances "Which function consumes the most self time?"
Event Log Chronological order of events "What happened exactly, and in what order?"

The self time / total time distinction is the one that confuses people most at first. Total includes everything the function calls; self is only the time inside its own body. render() can have a total of 310 ms and a self of 2 ms: it is not that render is slow, it is that it calls something that is. Bottom-Up sorted by self time is the view that finds the culprit.

Opening the first recording of Nómada Tasks with 600 tasks and sorting Bottom-Up by self time, this appears:

Function Self time Total Occurrences
paintCard 186 ms 218 ms 600
Recalculate Style 74 ms 74 ms 41
Layout 63 ms 63 ms 38
#visible 21 ms 24 ms 23
Board.summary 18 ms 96 ms 69

That is no longer "it feels slow": it is "600 calls to paintCard cost 186 ms of self time, and there are 41 style recalculations where there should be one". Lessons 09-02 and 09-04 attack exactly those rows.

  1. The Network panel and network and CPU throttling

You already used Network in 08-01 to debug requests. Here you use it for something else: knowing how much gets downloaded, in what order and what is blocking rendering.

Four columns you should always look at:

Column What it tells you
Size Two values: transferred (compressed) and actual. If they match, there is no compression
Time Total duration, with the breakdown in the Timing tab
Priority How the browser prioritizes that resource (Highest, High, Low)
Waterfall The cascade: what is waiting on what

And three settings in the top bar:

  • Disable cache: mandatory for measuring the first visit. Without it you are measuring your cache, not your users'.
  • Network throttling: Slow 4G is the realistic mobile profile.
  • CPU throttling: it lives in the Performance tab and in Rendering; 4× slowdown approximates a mid-range phone, a modest one.

At the foot of the panel, the summary bar gives the figure that matters for 09-05: requests, kB transferred, kB of resources and the DOMContentLoaded and Load times. For Nómada Tasks, with unbundled ES modules (05-04), that bar today says:

31 requests · 238 kB transferred · 512 kB resources · DOMContentLoaded 1.9 s · Load 4.3 s

Thirty-one requests because every ES module is a separate file (07-05 already warned about this when we wrote the precache list). That number is a problem for 09-05, not for today; today we just write it down.

  1. Instrumenting your own code: performance.now() and the User Timing API

The tools above measure the page. To measure your code —a specific function, a specific phase— you have to instrument it.

8.1 performance.now()

Date.now() returns whole milliseconds since 1970 and can jump backwards if the system clock is adjusted. performance.now() returns milliseconds since the page was opened, is monotonic (it never goes backwards) and has sub-millisecond resolution (deliberately reduced for security reasons: in practice, a granularity of tens of microseconds).

const started = performance.now();
view.render();
console.log(`render: ${(performance.now() - started).toFixed(1)} ms`);

8.2 performance.mark and performance.measure

console.time/console.timeEnd are fine for a quick glance, but they only produce console output. The User Timing API does something far better: the measurements appear in the Performance panel, in a lane of their own (Timings), lined up with the flame chart.

// js/util/measure.js

/** Marks the start of a named phase. */
export function start(name) {
  performance.mark(`${name}:start`);
}

/**
 * Closes the phase, creates the measure (visible in the Performance panel)
 * and returns its duration in milliseconds.
 */
export function end(name) {
  performance.mark(`${name}:end`);
  const measure = performance.measure(name, `${name}:start`, `${name}:end`);

  // Cleanup: if you never clear the marks, the buffer grows for the whole session
  performance.clearMarks(`${name}:start`);
  performance.clearMarks(`${name}:end`);
  performance.clearMeasures(name);

  return measure.duration;
}

/** Wraps a function so you can measure it without cluttering its body. */
export function measured(name, fn) {
  return function (...args) {
    start(name);
    try {
      return fn.apply(this, args);
    } finally {
      console.log(`${name}: ${end(name).toFixed(1)} ms`);   // finally: measures even if it throws
    }
  };
}

Three details of that code worth understanding:

  • performance.measure has returned the PerformanceMeasure object for years now, so there is no need to hunt it down with getEntriesByName.
  • The finally guarantees that the measurement is closed even if the function throws; without it, an orphaned mark breaks the next measure.
  • The cleanup matters: the performance entry buffer has a limited size and old marks push out new ones.

Instrumenting BoardView.render with this and recording with the panel open, the Timings lane shows a render bar of 310 ms lined up perfectly with the yellow block on the main thread. You no longer have to guess which part of the flame chart is yours.

// js/view/board-view.js — temporary instrumentation
import { start, end } from '../util/measure.js';

render() {
  start('render');
  const visible = this.#visible();

  start('render:columns');
  // …reconcile the three columns…
  end('render:columns');

  start('render:summary');
  const r = this.#state.board.summary(this.#state.today);
  // …
  end('render:summary');

  end('render');
}

With nested phases you get the breakdown without having to interpret the flame chart:

Phase Duration (median of 9)
render 310 ms
├─ render:columns 291 ms
└─ render:summary 4.2 ms

Keep the instrumentation out of production or behind a flag. Marks are cheap, but console.log calls inside loops are enormously expensive, and on top of that they skew the very measurement you are taking.

  1. Measuring in production: PerformanceObserver and web-vitals

For field data you have to collect metrics from real users. The base API is PerformanceObserver, which notifies you of performance entries as they occur.

// js/util/vitals.js — hand-rolled version, to understand the mechanism

// Long tasks: any block over 50 ms that never yields the thread
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn(`Long task: ${entry.duration.toFixed(0)} ms`, entry.attribution);
  }
}).observe({ type: 'longtask', buffered: true });

// LCP: it arrives several times; the LAST one before the first interaction is the good one
let lcp = 0;
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  lcp = entries[entries.length - 1].startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });

Two far from obvious details:

  • buffered: true also delivers entries that occurred before the observer was created. Without it you miss LCP, which usually happens before your script runs.
  • LCP changes as larger content is painted. The definitive value is the last one before the user interacts or the page is hidden.

Implementing LCP, INP and CLS properly by hand is surprisingly delicate (CLS session windows, INP event grouping, pages restored from the back/forward cache…). For production you use the official web-vitals library, which weighs little more than 2 kB:

npm install web-vitals
// js/util/vitals.js — production version
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals';

/**
 * Sends a metric to the server. `sendBeacon` survives page unload,
 * which a normal fetch does not guarantee.
 */
function send(metric) {
  const body = JSON.stringify({
    name: metric.name,        // 'LCP' | 'INP' | 'CLS' | 'TTFB' | 'FCP'
    value: metric.value,
    rating: metric.rating,    // 'good' | 'needs-improvement' | 'poor'
    id: metric.id,            // identifies the visit
    path: location.pathname
  });

  navigator.sendBeacon?.('/api/vitals', body)
    ?? fetch('/api/vitals', { body, method: 'POST', keepalive: true });
}

onLCP(send);
onINP(send);
onCLS(send);
onTTFB(send);
onFCP(send);

With that, and aggregating on the server by the 75th percentile, Taller Nómada would have its field data. Remember: CLS and INP are only fully known at the end of the visit, which is why sendBeacon (imitated by the fetch API with keepalive) is the right way to send them.

  1. How to run an honest benchmark

In 07-07 you already wrote a measure function to compare JavaScript with WebAssembly, and you already used two of the rules: warm-up and median. It is time to formalize them, because the rest of the module depends on your measurements meaning something.

Rule Why What happens if you break it
Warm up before measuring The JIT (09-02) needs several runs to optimize The first run is 3–10× slower: invalid comparison
Repeat (≥ 7 times) One measurement is an anecdote You are measuring system noise
Use the median, not the mean A garbage collector pause blows up the mean An outlier decides your conclusion
Also look at the minimum It is the "no interference" case You cannot tell slow code from a busy machine
Compare against a reference Absolute numbers do not transfer between machines "42 ms" means nothing without a "compared to what"
Change one variable only If two things change, you do not know which acted Non-attributable conclusion
Close everything else Other tabs, builds, antivirus Enormous, irregular noise

Here is the complete utility we will use throughout the module:

// js/util/bench.js

/**
 * Runs `fn` repeatedly and returns robust statistics.
 * @param {string} name      Label for the report.
 * @param {Function} fn      Function to measure. It must be self-contained.
 * @param {object} options
 * @param {number} options.warmup  Discarded runs (JIT).
 * @param {number} options.runs    Measured runs.
 */
export function bench(name, fn, { warmup = 5, runs = 15 } = {}) {
  for (let i = 0; i < warmup; i += 1) fn();             // 1 · warm up: not measured

  const times = [];
  for (let i = 0; i < runs; i += 1) {                   // 2 · measure
    const t0 = performance.now();
    fn();
    times.push(performance.now() - t0);
  }

  times.sort((a, b) => a - b);                          // 3 · robust statistics
  const p = (q) => times[Math.min(times.length - 1, Math.floor(times.length * q))];

  return {
    name,
    min: times[0],
    median: p(0.5),
    p95: p(0.95),
    max: times.at(-1)
  };
}

/** Compares two alternatives on the same input and reports the improvement. */
export function compare(a, b, options) {
  const ra = bench(a.name, a.fn, options);
  const rb = bench(b.name, b.fn, options);
  console.table([ra, rb].map((r) => ({
    Variant: r.name,
    'Median (ms)': r.median.toFixed(2),
    'Min (ms)': r.min.toFixed(2),
    'p95 (ms)': r.p95.toFixed(2)
  })));
  console.log(`Improvement: ${(ra.median / rb.median).toFixed(2)}×`);
  return { a: ra, b: rb };
}

The p95 deserves a comment: the median tells you how it normally goes, the p95 tells you how it goes in the worst 5% of cases. For perceived performance the p95 often matters more, because it is the one that produces the jerk the user remembers.

And a golden rule that saves entire arguments: if the difference between two variants is smaller than the variability of your own measurement, there is no difference. If the median is 12 ms and the range runs from 9 to 18 ms, an "improvement" to 11.4 ms is noise.

  1. Your machine lies: simulating a modest phone

Your development laptop has a fast processor, plenty of RAM, an SSD, a wired connection and a warm cache. Your users do not. The gap between a development laptop and a typical mid-range phone is around 4–6× on single-thread CPU, and it is bigger still on a four-year-old budget phone.

Translated: those 310 ms of render() that you measure are more than a second and a half on the phone Iván checks the board from when he is away from the workshop.

How to simulate it:

What to simulate Where Recommended setting
Slow CPU Performance → CPU: 4× slowdown 4× for mid-range, 6× for low-end
Slow network Network → Throttling Slow 4G
Small screen Device toolbar (Ctrl+Shift+M) A specific phone from the list
All at once Lighthouse, mobile profile It is the default

Two honest warnings about CPU throttling: it is a simulation (it slows execution down uniformly, whereas a real phone also has less cache, slower memory and thermal throttling), and it does not slow down the network or the disk. It is a useful approximation, not a truth. If your product matters, test on a real mid-range device with remote debugging; that is the one measurement nobody argues with.

And the methodological consequence: every figure in this module was measured on one specific machine —a development laptop with 4× CPU throttling and Slow 4G network, Chrome in incognito mode— and they serve to illustrate proportions and orders of magnitude, not as universal values. On your equipment other numbers will come out. What will not change is the conclusion: 600 calls to paintCard cost three orders of magnitude more than a sort.

  1. The baseline for Nómada Tasks with 600 tasks

We now have method and tools. Let us establish the starting number for the whole module.

First, the data. We need a realistic and reproducible board of 600 tasks: if every run generates different data, the measurements are not comparable. A generator with a fixed seed solves that.

// test/helpers/large-backlog.js
import { Task } from '../../js/model/task.js';

const ASSIGNEES = ['Iván', 'Lucía', 'Marta'];
const PRIORITIES = ['high', 'medium', 'low'];
const STATUSES = ['pending', 'in-progress', 'done'];
const TAGS = ['space', 'screen-printing', 'web', 'carpentry', 'bookbinding', 'purchasing'];

/** Linear congruential generator: pseudorandom but REPRODUCIBLE. */
function seededRandom(seed) {
  let s = seed;
  return () => {
    s = (s * 1664525 + 1013904223) % 4294967296;
    return s / 4294967296;
  };
}

/**
 * Two years of Taller Nómada: 600 tasks with the same shape as the canonical
 * backlog of 6, so the model never notices the difference.
 */
export function createLargeBacklog(n = 600, seed = 20260920) {
  const r = seededRandom(seed);
  const tasks = [];

  for (let i = 1; i <= n; i += 1) {
    const day = 1 + Math.floor(r() * 27);
    const month = 1 + Math.floor(r() * 12);
    tasks.push(new Task({
      id: i,
      title: `Task ${i} · ${TAGS[Math.floor(r() * TAGS.length)]}`,
      assignee: ASSIGNEES[Math.floor(r() * 3)],
      priority: PRIORITIES[Math.floor(r() * 3)],
      status: STATUSES[Math.floor(r() * 3)],
      tags: [TAGS[Math.floor(r() * TAGS.length)]],
      estimatedHours: 1 + Math.floor(r() * 16),
      dueDate: `2026-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
      reviewer: r() > 0.5 ? ASSIGNEES[Math.floor(r() * 3)] : null
    }));
  }
  return tasks;
}

With the same seed, always the same 600 tasks. That is what makes it possible to compare today's measurement with the one four lessons from now.

And this is the baseline, measured with the procedure from section 10 (median of 15 runs after 5 warm-up runs, CPU 4×, Slow 4G, incognito, on the reference laptop):

# Measurement How it is obtained Baseline Target Lesson that attacks it
1 LCP (simulated mobile) Mobile Lighthouse 4.1 s ≤ 2.5 s 09-05
2 INP when typing in the search box Performance, Interactions 480 ms ≤ 200 ms 09-02, 09-04
3 CLS Mobile Lighthouse 0.21 ≤ 0.1 09-05
4 Longest task at startup Performance, Main 1,180 ms ≤ 200 ms 09-02
5 Full render(), 600 tasks User Timing 310 ms ≤ 50 ms 09-04
6 summary() recalculations per filtering User Timing 96 ms ≤ 5 ms 09-02
7 Planning report (blocks the thread) User Timing 940 ms 0 ms on the main thread 09-02
8 Memory retained after 200 filterings Memory, 3 snapshots +37.7 MB ≈ 0 09-03
9 DOM nodes in the document Performance, counter 7,812 ≤ 1,500 09-04
10 JS downloaded before the 1st card Network 214 kB / 28 requests ≤ 80 kB / ≤ 5 09-05

Ten numbers. Not one opinion. This table is the contract of the module: the last lesson will repeat it with an "after" column.

Notice that the table also contains an implicit diagnosis: there are four distinct problems —code that takes too long, memory that is retained, a DOM being abused and excessive downloading— and each has its own lesson. None of that was known before measuring; it was perfectly possible that everything was a single network problem.

  1. The performance budget and continuous integration

A one-off measurement degrades. Three months from now somebody will add a 90 kB charting library and nobody will notice until a user complains. A performance budget is a set of thresholds that, when exceeded, break the build: exactly like a Jest test failing in 08-03.

There are three kinds of budget and it is worth having all three:

Kind Example Tool
Quantity ≤ 80 kB of initial JS; ≤ 5 requests Bundler (09-05)
Time LCP ≤ 2.5 s; TBT ≤ 200 ms Lighthouse CI
Rule Every image with width/height Lighthouse audit

Lighthouse CI handles the middle one with very little configuration:

npm install --save-dev @lhci/cli
{
  "ci": {
    "collect": {
      "url": ["http://localhost:4173/"],
      "numberOfRuns": 3,
      "startServerCommand": "npm run preview"
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "total-blocking-time":      ["error", { "maxNumericValue": 200 }],
        "cumulative-layout-shift":  ["error", { "maxNumericValue": 0.1 }],
        "first-contentful-paint":   ["warn",  { "maxNumericValue": 1800 }],
        "uses-responsive-images":   "off"
      }
    },
    "upload": { "target": "temporary-public-storage" }
  }
}

Three decisions in that file that are not cosmetic:

  • numberOfRuns: 3. It applies the repetition rule; Lighthouse CI keeps the median.
  • error versus warn. Only what you are willing to block goes in as error. A budget that breaks the build every day ends up disabled.
  • "uses-responsive-images": "off". Explicitly turning off what does not apply is better than ignoring it: it puts the decision on the record.

And the step in the GitHub Actions workflow, following the same cheap-things-first order as 08-06:

# .github/workflows/ci.yml (fragment)
  performance:
    needs: [tests]                # cheap stuff first: unit tests, then this
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npm run build
      - run: npx lhci autorun     # exits with code 1 if the budget is broken

Two important warnings about measuring performance in CI. First: shared runners are noisy; their timings vary far more than your machine's, so set the thresholds with headroom and distrust a 5% regression. Second: quantity budgets (kilobytes, number of requests, number of modules) are far more stable than time budgets, and that is why they are the first line of defense. You will see them applied in 09-05.

Common Mistakes and Tips

  • Optimizing without measuring. This is the mother of all mistakes. It produces more complicated code, sometimes slower, and always harder to maintain.
  • Measuring only once. One measurement is an anecdote. Seven repetitions minimum, and take the median.
  • Using the mean instead of the median. A single garbage collector pause blows it up and leads you to a false conclusion.
  • Measuring without warm-up. The first run is not JIT-optimized and can be ten times slower.
  • Measuring with extensions enabled. They inject scripts and observers. Always measure in incognito.
  • Measuring with a warm cache. Without Disable cache you are measuring your second visit, not your user's first.
  • Measuring only on your laptop. Throttle the CPU by at least 4×, or you will be optimizing for yourself.
  • Chasing a Lighthouse score of 100. The score is a compressed weighted average; the last few points cost a fortune and add nothing perceptible.
  • Confusing lab with field. If your Lighthouse says 1.9 s and Search Console says 3.8 s, neither is lying: one is your machine and the other is the 75th percentile of reality.
  • Optimizing TTFB by touching JavaScript. TTFB belongs to the server. Diagnose before acting.
  • Changing five things at once. If it improves, you will not know which one helped; if it gets worse, you will not know either.
  • Leaving instrumentation console.logs in production. They cost, and inside a loop they cost enormously.
  • Tip: save your recordings. The Performance panel lets you export the trace as .json. Save the "before" one: it is the only way to compare properly.
  • Tip: always look at self time in Bottom-Up. Total time points at the caller; self time points at the culprit.
  • Tip: write the target down before measuring. "Filtering should respond in under 200 ms" is a goal; "make it faster" is not.
  • Tip: if an optimization does not improve the measurement, revert it. Complexity without benefit is pure debt.

Exercises

Exercise 1 — The three-sentence diagnosis. Marta says "it feels slow", Iván says "it jams when I type" and Lucía says "it takes ages to load". For each sentence, state: (a) which Web Vital captures it, (b) which tool you would use to confirm it, (c) which throttling setting you would apply and (d) which specific measurement from the baseline table it corresponds to. Then explain why the three sentences cannot be fixed with the same change.

Exercise 2 — An honest benchmark. Using bench() from section 10, write a script that compares two ways of computing the open hours of the 600 tasks: (a) tasks.filter(t => t.isOpen()).reduce((s, t) => s + t.estimatedHours, 0) and (b) a single for loop with an accumulator. Run it with 5 warm-up runs and 15 measured runs, and answer honestly: is there a significant difference? Justify your answer with the median and p95 numbers, and say which version you would put in the code.

Exercise 3 — A budget for the search box. INP when typing in the search box is 480 ms. Write (a) the numeric target and its perceptual justification, (b) the instrumentation snippet with performance.mark/measure that would measure exactly the "keypress → board repainted" journey, and (c) a Lighthouse CI assertion that breaks the build if interactivity degrades. Explain why the assertion cannot use INP directly.

Solutions

Solution 1

Sentence (a) Web Vital (b) Tool (c) Throttling (d) Measurement
"It feels slow" (Marta) None specifically: it is an aggregate symptom. It decomposes into LCP + INP Performance panel, recording of a usage session CPU 4× It is the sum of measurements 4, 5 and 9
"It jams when I type" (Iván) INP Performance, Interactions lane; in the lab, TBT CPU 4× (the network plays no part) Measurements 2 and 6
"It takes ages to load" (Lucía) LCP (with TTFB and FCP as diagnostics) Mobile Lighthouse + Network panel CPU 4× and Slow 4G network Measurements 1 and 10

They cannot be fixed with the same change because they happen at different moments and have different causes: Lucía's happens before a single line of your render code runs —it is a problem of how much gets downloaded and in what order (09-05)—, while Iván's happens with everything already loaded and is pure main-thread work (09-02 and 09-04). Optimizing the bundle will not fix the jam when typing, and chunking the render will not make the first load faster. Marta's is the aggregate perception of the other two and will disappear when both of them do.

Solution 2

// bench/open-hours.js
import { Board } from '../js/model/board.js';
import { createLargeBacklog } from '../test/helpers/large-backlog.js';
import { compare } from '../js/util/bench.js';

const tasks = createLargeBacklog(600);

const withChain = () =>
  tasks.filter((t) => t.isOpen()).reduce((s, t) => s + t.estimatedHours, 0);

const withLoop = () => {
  let total = 0;
  for (let i = 0; i < tasks.length; i += 1) {
    if (tasks[i].isOpen()) total += tasks[i].estimatedHours;
  }
  return total;
};

console.assert(withChain() === withLoop(), 'both versions must give the same result');
compare({ name: 'filter+reduce', fn: withChain }, { name: 'for', fn: withLoop },
        { warmup: 5, runs: 15 });

Result on the reference laptop (no throttling, because here we are comparing code, not experience):

Variant Median (ms) Min (ms) p95 (ms)
filter+reduce 0.061 0.052 0.094
for 0.038 0.031 0.071

There is no significant difference for practical purposes. Yes, the for loop is 1.6× faster on the median, but the absolute difference is 0.023 ms: twenty microseconds, against the 310 ms that rendering the same screen costs. It is 13,000 times smaller than the real problem. On top of that, the ranges of the measurements (0.052–0.094 against 0.031–0.071) partially overlap, a sign that the noise is of the same order as the effect.

I would put filter/reduce in the code: it reads better, it expresses the intent and its cost is irrelevant. This exercise is precisely the experimental demonstration of why the myth that "for is faster than array methods" should not drive design decisions. We will come back to it in 09-02 with the full table of myths.

Solution 3

(a) Target: INP ≤ 200 ms, and within that, the keypress → repaint journey ≤ 100 ms. The justification is the perceptual threshold from section 2: below about 100 ms the response feels instantaneous; 200 ms is the limit the INP metric considers "good" because it also includes the input delay and the paint, which you do not fully control.

(b) Instrumentation. The key point is that the measurement cannot end when render() finishes: it has to end when the browser has painted. That is achieved with a nested requestAnimationFrame (07-06): the first one runs before the paint, the second one right after it.

// js/view/controller.js — instrumenting the search box
import { start, end } from '../util/measure.js';

searchField.addEventListener('input', (event) => {
  start('search');

  view.update({ filters: { text: event.target.value } });

  // The paint happens AFTER the handler; you have to wait two frames
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      const ms = end('search');
      if (ms > 100) console.warn(`Slow search: ${ms.toFixed(0)} ms`);
    });
  });
});

(c) CI assertion:

"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"max-potential-fid":   ["warn",  { "maxNumericValue": 130 }]

INP cannot be used directly because it is a field metric: it needs a real user to interact, and in Lighthouse CI nobody interacts. What you can police is TBT, which measures the total time the main thread is blocked and which correlates well with a bad INP: if there are no long tasks, there is no way for an interaction to take 480 ms. To police INP for real you need the web-vitals setup from section 9 sending data from production, with an alert if the 75th percentile goes above 200 ms.

Conclusion

You have changed the starting point for the whole module. "Nómada Tasks feels slow" is no longer a sentence: it is a table of ten numbers measured with a repeatable procedure on a board of 600 tasks generated from a fixed seed.

You know why intuition fails and what Knuth actually said: not "don't optimize", but "don't optimize what you haven't measured, because programmers' intuitive guesses fail". You know the thresholds that define "fast" for a person —100 ms to feel a response, 1 s to keep the train of thought, 16.7 ms per frame at 60 Hz, with under 10 ms of JavaScript inside— and you can translate them into falsifiable targets.

You have mastered the Web Vitals: LCP (2.5 s) as the measure of when the main content appears, INP (200 ms) as the measure of every interaction in the session —not just the first one, as with the old FID—, CLS (0.1) as the measure of visual stability, and TTFB and FCP as diagnostic instruments that tell you where the LCP problem is. And you understand why your local Lighthouse and the field data never agree: one is your machine with a warm cache, the other is the 75th percentile of real phones on real networks.

You have the complete toolkit: Lighthouse read by metrics rather than by score, with TBT as the lab stand-in for INP; the Performance panel with its fixed procedure —incognito, throttle, record little— and its layered reading, with long tasks marked in red and the distinction between Call Tree (who caused it) and Bottom-Up sorted by self time (who consumes it); the Network panel with Disable cache and Slow 4G; the User Timing API with performance.mark/measure so that your own phases appear on the timeline; and PerformanceObserver with the web-vitals library and sendBeacon for field data. And you know how to run an honest benchmark: warm up, repeat, median and p95, compare against a reference, change one single variable, and dismiss as noise any difference smaller than the variability of the measurement itself.

Above all, you have the contract of the module: 4.1 s of LCP, 480 ms of INP, 0.21 of CLS, a long task of 1,180 ms, 310 ms of render, 96 ms of recalculations, a 940 ms blocking report, 37.7 MB retained, 7,812 nodes and 214 kB in 28 requests. With its corresponding performance budget and an lhci autorun that breaks continuous integration when somebody misses it, because a budget nobody polices stops existing within three months.

The table also divides up the work. Rows 2, 4, 6 and 7 —INP, the long task, the recalculations and the 940 ms report that freezes the screen— are your code taking too long to run: algorithms with the wrong cost, repeated calculations that could be done once, handlers that fire sixty times a second and heavy work that insists on occupying the one thread that paints the interface. That is where most of the lost second lives, and that is where the next lesson begins: Optimizing JavaScript Performance, where you will see how the engine works internally —just enough not to sabotage it—, swap a quadratic search for a Map index, put a properly invalidated cache into Board, learn when to debounce and when to throttle, chunk long work so the event loop can breathe and, finally, take the planning report off the main thread by moving it into a Web Worker. All of it with the same discipline: measure before, measure after, and revert whatever does not improve.

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