Three rows of the baseline remain and none of them is fixed by writing better JavaScript, because they all happen before the first line of your code runs: 4.1 s of LCP, 0.21 of CLS and 214 kB in 28 requests before the first card appears. During those four seconds, the Map index from 09-02, the leak cleanup from 09-03 and the virtual window from 09-04 do not exist yet: Lucía stares at a blank screen on her phone, on the train, on Slow 4G. This lesson is about what decides that time: what the browser downloads, in what order, and how much of it is actually used. You will see the critical rendering path and why CSS blocks while JavaScript may not, closing what 01-03 and 06-01 noted about defer, async and type="module"; you will measure the real weight with the Network panel and the Coverage tab; you will finally understand what a bundler does and why it exists, with a minimal Vite configuration for Nómada Tasks; you will close the reference 05-04 left open about tree shaking; you will split the code with dynamic import(); you will learn to preload with preload, modulepreload, prefetch, preconnect and dns-prefetch; you will make images and components lazy; you will fix the fonts, which is where half the CLS lives; and you will close with compression, HTTP caching and its relationship with the service worker from 07-05. At the end, the ten-row table, complete, with its "before" and "after" columns.
Contents
- The performance that is decided before a single line runs
- The critical rendering path
- Why CSS blocks and JavaScript may not
- Measuring the real weight: the Network panel
- The Coverage tab: how much downloaded code goes unused
- Row 10's problem: 28 unbundled ES modules
- What a bundler does and why it exists
- Vite for Nómada Tasks: a minimal, annotated configuration
npm run build: what it generates and what changes inindex.html- Minification
- Tree shaking: why ES modules make it possible
- Code splitting with dynamic
import() - The loading pattern: indicator, cancellation and failure
- Preloading:
preload,modulepreload,prefetch,preconnect,dns-prefetch - Lazy loading images
- Lazy loading components with
IntersectionObserver - Fonts:
font-display, subsetting, preloading and fallback metrics - Closing row 3: where the CLS of 0.21 came from
- Compression: Gzip and Brotli
- HTTP caching, hashing and the service worker from 07-05
- The performance budget in continuous integration
- The complete table: the ten rows, before and after
- What has not been solved and what it cost
- Common Mistakes and Tips
- Exercises
- Conclusion
- The performance that is decided before a single line runs
There is an uncomfortable asymmetry in web performance. The previous three lessons have optimized execution: algorithms, memory, DOM manipulation. But for anything to run, it first has to be downloaded, parsed and compiled, and that preliminary work has rules of its own.
Look at the real timeline of Nómada Tasks in the baseline, at Slow 4G and CPU 4× (which is what Lighthouse simulates by default and what Lucía really has on the train):
| Moment | t | What is happening |
|---|---|---|
| Document request | 0 ms | |
| First byte (TTFB) | 610 ms | The server responds |
| HTML parsed | 780 ms | The CSS and the entry module are discovered |
| CSS downloaded and applied | 1,240 ms | Up to here, a mandatory blank screen |
| FCP | 2,300 ms | The header appears |
| The 28 ES modules downloaded | 3,150 ms | In a cascade, by levels of the graph |
app.js finishes running |
3,790 ms | |
| First card painted (LCP) | 4,100 ms |
Not one of those milliseconds is fixed with a Map or a virtual window. Of the 4.1 seconds, your code runs for 310 (which 09-04 brought down to 31). The other 3.8 seconds are network, parsing and waiting.
The methodological consequence is the same as 09-01's, applied to another axis: the bottleneck moves. You optimized the render until it was ten times faster, and now the render is 1% of the first-load problem. Optimizing the render further would achieve nothing; what helps is downloading less and in a better order.
And there is a second, less obvious reason why downloading less code also speeds up execution. In 09-02 you saw that V8's parser is lazy: it does not fully parse a function's body until it is about to run, but it does have to walk the whole file to know where each function ends. A 200 kB module costs parsing time even if you only use one function from it. Reducing initial JavaScript is not just a network optimization: it is also a CPU optimization at startup, and therefore one for row 4 of the baseline.
- The critical rendering path
The critical rendering path is the minimum sequence of resources the browser needs to paint the first pixel of content. Everything on that path delays FCP and, almost always, LCP.
flowchart TD
A["Document request"] --> B["HTML arrives in chunks"]
B --> C["The parser builds the DOM"]
C -->|"finds <link rel=stylesheet>"| D["Download CSS<br/><b>BLOCKS rendering</b>"]
C -->|"finds <script> with no defer/module"| E["Download and run JS<br/><b>BLOCKS parsing</b>"]
C -->|"finds <script type=module>"| F["Download in parallel<br/>run once the DOM is done"]
D --> G["Complete CSSOM"]
C --> H["Complete DOM"]
G --> I["Render tree"]
H --> I
I --> J["Layout → Paint → FCP"]
F --> K["The JS mutates the DOM"]
K --> L["LCP: the main content appears"]
J --> L
style D fill:#fdd,stroke:#c00
style E fill:#fdd,stroke:#c00
style F fill:#dfd,stroke:#090
Two ideas should come out of this diagram, and they are different from each other:
CSS blocks rendering. The browser paints absolutely nothing until it has all the CSS that applies to the page. The reason is common sense: if it painted earlier, the page would appear unstyled and would jump as soon as the styles arrived, which is called FOUC (flash of unstyled content) and produces an appalling CLS. So the browser prefers blankness to a lie.
A classic <script> blocks parsing. When the parser finds a <script src> with no defer and no async, it stops: it downloads the file, runs it, and only then carries on building the DOM. And since the script could call document.write, there is no way around it. That is the historical reason for putting scripts at the end of the <body>.
Nómada Tasks has one CSS file (css/styles.css, 18 kB) and one entry point (<script type="module" src="js/app.js">). The CSS blocks for 460 ms on Slow 4G, and the module does not block parsing but it does drag in the cascade of its 27 dependencies.
- Why CSS blocks and JavaScript may not
In 01-03 and 06-01 you already saw the table of defer, async and type="module". We bring it back here, extended with what matters for the critical path:
| Form | Does it block HTML parsing? | When it runs | Order guaranteed? | Appropriate use |
|---|---|---|---|---|
<script src> |
Yes, while downloading and running | Immediately | Yes | Practically never |
<script defer src> |
No | After the document is parsed, before DOMContentLoaded |
Yes | Application code |
<script async src> |
Not while downloading; yes while running | As soon as it is downloaded | No | Independent scripts (analytics) |
<script type="module"> |
No (it is implicitly defer) |
After the document is parsed | Yes | What Nómada Tasks uses |
<script type="module" async> |
Not while downloading; yes while running | As soon as it is downloaded | No | Modules with no DOM dependencies |
Three clarifications that prevent frequent mistakes:
async is not "better than defer". It is different. An async script runs as soon as it arrives, which means it can run in the middle of HTML parsing and block the main thread at the worst possible moment. And since it does not guarantee order, two interdependent async scripts fail intermittently. defer is the sensible default; async only for code that depends on nothing and nobody.
CSS can also be taken off the critical path. A <link rel="stylesheet"> blocks; but it can be marked as non-blocking with the media trick:
<!-- Blocking: the styles needed to paint what is visible at the start -->
<link rel="stylesheet" href="/assets/styles-8f3a1c.css">
<!-- Non-blocking: styles only needed later (printing, modals) -->
<link rel="stylesheet" href="/assets/print-2b7e40.css" media="print">
<link rel="stylesheet" href="/assets/modals-9c1d5a.css" media="print" onload="this.media='all'">The second pattern works because the browser downloads CSS whose media does not match at low priority, and does not block rendering for it; on load, the onload changes media to all and the styles apply. It is a well-known and legitimate trick, but it is worth using sparingly: in Nómada Tasks it only makes sense for the print CSS.
Inline <style> does not block the network but it does take up bytes in the HTML. The critical CSS technique consists of inlining in the <head> the few styles needed to paint the visible part and loading the rest non-blockingly. It is effective —in Nómada Tasks it saves about 300 ms of FCP— but it requires generating that fragment automatically during the build, because maintaining it by hand guarantees it will go stale. We mention it as an advanced option and will not apply it: with an 18 kB stylesheet, the benefit does not justify the complexity.
- Measuring the real weight: the Network panel
You already used Network in 08-01 for debugging and in 09-01 to record the baseline. Here we use it as a measuring instrument, with a fixed procedure:
- An incognito window.
- Disable cache ticked. Without it you are measuring your second visit.
- Throttling:
Slow 4G. - Reload and wait for everything to finish.
- Look at the summary bar at the foot and sort by Size and by Time.
The columns that matter, extending 09-01's table:
| Column | What it tells you | Warning sign |
|---|---|---|
| Size | Two values: transferred / resource | If they match, there is no compression |
| Priority | How the browser prioritizes it | Your LCP image at Low is a problem |
| Waterfall | What is waiting on what | Steps = dependency cascade |
| Initiator | Who requested the resource | Finds unexpected imports |
| Protocol | h2, h3, http/1.1 |
On HTTP/1.1, many requests really do hurt |
The Nómada Tasks summary bar today, already quoted in 09-01:
And the breakdown by type, which is what tells you where to work:
| Type | Requests | Transferred | Resources | Comment |
|---|---|---|---|---|
| Document | 1 | 3.1 kB | 9.4 kB | |
| CSS | 1 | 4.8 kB | 18 kB | Compressed, correct |
| JavaScript | 28 | 214 kB | 214 kB | Uncompressed, unminified |
| Fonts | 1 | 94 kB | 94 kB | One complete font |
| Images | 2 | 41 kB | 41 kB | No declared dimensions |
| Total | 31 | 238 kB | 512 kB |
Three things jump out and all three belong to this lesson: 28 JavaScript requests, uncompressed and unminified, a 94 kB font that turns out to be the second heaviest resource on the page, and two images with no dimensions, which is the classic CLS generator.
A note about HTTP/2 and HTTP/3, because a half-truth is going around. It is true that with HTTP/2 many requests no longer cost what they cost on HTTP/1.1: there is multiplexing over a single connection and no six-parallel-requests-per-domain limit. But every request still has a cost: headers, a round trip if it is not within the congestion window, and —the important one for ES modules— a cascade by levels. And that cascade is exactly row 10's problem.
- The Coverage tab: how much downloaded code goes unused
There is a tool in DevTools that almost nobody opens and that answers the most uncomfortable question: of everything you have downloaded, how much has actually run?
You open it from the command menu (Ctrl+Shift+P → Show Coverage), press the reload button, and a table appears with one bar per file: in red the code downloaded and unused, in blue the used code.
The honest procedure has a nuance: Coverage measures up to the moment you stop the recording. If you stop right after loading, you will see a sky-high unused percentage that does not mean "dead code" but "code not run yet". The correct reading is:
- Stop just after the LCP to find out how much code is surplus on the critical path → that is what needs splitting.
- Stop after using the whole application to find out how much code is genuinely dead → that is what needs deleting.
The result for Nómada Tasks, stopping just after the first card:
| File | Bytes | Unused | % |
|---|---|---|---|
js/planning/report.js |
34.1 kB | 34.1 kB | 100% |
js/view/description-editor.js |
41.8 kB | 41.8 kB | 100% |
js/util/format.js |
12.4 kB | 7.9 kB | 64% |
js/data/tasks-api.js |
9.2 kB | 6.1 kB | 66% |
js/view/form.js |
11.7 kB | 9.4 kB | 80% |
js/i18n/*.js (3 languages) |
12.3 kB | 8.2 kB | 67% |
| The rest | 92.5 kB | 25.1 kB | 27% |
| Total JavaScript | 214 kB | 132.6 kB | 62% |
62% of the JavaScript Lucía downloads on the train does not run before she sees the first card. And two files —the planning report computation and the rich description editor— add up to 76 kB at 100% unused: they are code only needed when somebody presses a button that most visits never press.
That is the plan of the lesson, and it comes from a measurement, not an intuition:
- What is never used: delete it (tree shaking, section 11).
- What is used later: load it later (code splitting, section 12).
- What is used always: compress, minify and cache it properly (sections 10, 19 and 20).
- Row 10's problem: 28 unbundled ES modules
Until now, Nómada Tasks has been served as-is: the browser receives app.js, sees its imports, requests those files, sees their imports, requests the next ones… It is clean, it is exactly what 05-04 taught, and in development it is wonderful. In production it has one concrete problem: the cascade.
flowchart TD
subgraph N1["Level 1 · 610 ms"]
A["app.js"]
end
subgraph N2["Level 2 · +280 ms"]
B["model/board.js"]
C["view/board-view.js"]
D["data/local-repository.js"]
end
subgraph N3["Level 3 · +280 ms"]
E["model/task.js"]
F["view/card.js"]
G["view/dom.js"]
H["data/http.js"]
end
subgraph N4["Level 4 · +280 ms"]
I["util/dates.js"]
J["util/format.js"]
K["util/time.js"]
end
A --> B & C & D
B --> E
C --> F & G
D --> H
E --> I
F --> J
G --> K
The browser cannot know that it needs util/format.js until it has downloaded and parsed view/card.js, which in turn needed view/board-view.js, which needed app.js. On Slow 4G, each level costs a round trip of about 280 ms. Four levels are 1.1 seconds of pure waiting, with barely any bytes downloaded.
And on top of that, the 28 files carry comments, long names and whitespace, because they are readable source code. The 214 kB are, in large part, air.
| Cost of serving raw ES modules | Magnitude |
|---|---|
| Discovery cascade (4 levels × 280 ms) | 1,120 ms |
| Headers and overhead of 28 requests | ~95 ms |
| Surplus bytes (comments, names, formatting) | ~96 kB |
| No compression (development server) | ~150 kB |
This is not a defect of ES modules: it is that they were designed to express the dependency graph, not to be the optimal delivery format. The tool that translates one into the other is the bundler.
- What a bundler does and why it exists
A bundler reads your entry point, walks the entire graph of imports, and produces one or more files optimized for delivery. Along the way it does several distinct things that are worth not conflating:
| Task | What it does | What it solves |
|---|---|---|
| Bundling | Joins modules into a few files | The cascade and the number of requests |
| Resolution | Finds import 'web-vitals' in node_modules |
The browser's ES modules cannot resolve bare imports |
| Minification | Removes whitespace and comments and shortens local names | Bytes |
| Tree shaking | Removes exports nobody imports | Bytes of dead code |
| Code splitting | Separates what is loaded on demand | Bytes on the critical path |
| Hashing | Renames to index-8f3a1c.js based on the content |
Eternal caching with no risk of serving stale files |
| Transformation | Compiles TypeScript, JSX, modern CSS | Compatibility |
| Assets | Turns images, fonts and CSS into hashed resources | Deployment consistency |
Of all of them, the one that on its own justifies the tool's existence is the second. This, which has always worked in Node, does not work in the browser:
The browser only understands paths: ./x.js, /x.js, https://…. A bare specifier like web-vitals means nothing to it. Import maps exist to resolve that by hand, but as soon as you have five dependencies with their own dependencies, maintaining them is unfeasible.
And an important clarification, because it is a constant source of confusion: bundling does not mean "one single file". That was the 2015 model, when HTTP/1.1 penalized requests heavily. Today the goal is "few files, well chosen": a core that is always needed, and separate chunks for what is only needed sometimes, each with its own hash so the cache works at a fine grain.
An overview of the tools, to get your bearings:
| Tool | What it is | When to choose it |
|---|---|---|
| Vite | Dev server with native modules + Rollup for production | The default today for a web application |
| Rollup | ESM-oriented bundler; the best tree shaking | Libraries |
| esbuild | Bundler and minifier written in Go; extremely fast | When speed rules |
| webpack | The veteran; the most configurable and the most complex | Large legacy projects |
| Parcel | Zero configuration | Prototypes |
| None | Native ES modules | Small projects, demos, learning |
We choose Vite for a reason that fits the module: in development it bundles nothing, it serves native ES modules exactly as before (which is why it starts instantly and reloading is immediate), and it only bundles when building for production. The code you have written across nine modules does not change by a single line.
- Vite for Nómada Tasks: a minimal, annotated configuration
// vite.config.js
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
// Project root: where index.html lives. Vite starts from the HTML, not the JS
root: '.',
// Base path used to generate asset URLs.
// If you deploy to https://taller.example/app/, this becomes '/app/'
base: '/',
build: {
outDir: 'dist', // output folder
assetsDir: 'assets', // inside dist/
emptyOutDir: true, // cleans dist/ before building
sourcemap: true, // source maps: debugging production (08-01)
target: 'es2022', // do not transpile more than needed: private fields, groupBy…
cssCodeSplit: true, // one CSS file per entry point
// Warn if a chunk exceeds the budget (section 21)
chunkSizeWarningLimit: 80, // in kB
rollupOptions: {
output: {
// Content-hashed names: eternal caching with no risk (section 20)
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]',
/**
* Manual chunks. Only two, and for a concrete reason:
* the model and the utilities change far less often than the view,
* so separating them means a change to the interface does not invalidate
* the cache of the stable part.
*/
manualChunks(id) {
if (id.includes('/js/model/') || id.includes('/js/util/')) return 'model';
if (id.includes('node_modules/web-vitals')) return 'vitals';
return undefined; // let Rollup decide about the rest
}
}
}
},
server: {
port: 5173,
open: true
},
preview: {
port: 4173 // the one Lighthouse CI uses in 09-01
},
// The visual bundle report: dist/bundle-report.html
plugins: [
visualizer({
filename: 'dist/bundle-report.html',
gzipSize: true,
brotliSize: true
})
]
});Five decisions in that file that are not cosmetic:
target: 'es2022'. Transpiling to ES5 "just in case" is an expensive mistake today: it inflates the bundle by 20–30% and adds polyfills that no browser with ES module support needs. Nómada Tasks uses private fields (05-03) andObject.groupBy(06-06);es2022keeps them as they are.sourcemap: true. Without source maps, a production error is an unreadable stack trace with one-letter names. The maps are generated as separate files and are not downloaded unless DevTools is open, so they cost the user nothing. Ship them, or at least ship them to your error-tracking service.manualChunkswith judgment, not out of habit. The reason for separatingmodelis not size, it is rate of change: the view is touched every week and the model every few months. Separating them means a deployment of the interface does not force users to download the model again.cssCodeSplit: truemeans each entry point has its own CSS, and the CSS of a dynamically loaded chunk is loaded with it.chunkSizeWarningLimit: 80is literally row 10's budget from the baseline, put into the tool.
And the package.json scripts:
{
"name": "nomada-tasks",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "jest",
"test:e2e": "cypress run",
"lint": "eslint js test",
"analyze": "vite build && open dist/bundle-report.html",
"budget": "node scripts/budget.mjs"
}
}
npm run build: what it generates and what changes in index.html
npm run build: what it generates and what changes in index.htmlvite v5.4.0 building for production... ✓ 34 modules transformed. dist/index.html 1.84 kB │ gzip: 0.79 kB dist/assets/styles-4c9e21.css 17.90 kB │ gzip: 3.91 kB │ brotli: 3.22 kB dist/assets/vitals-9c1d5a.js 2.61 kB │ gzip: 1.18 kB │ brotli: 1.02 kB dist/assets/model-2b7e40.js 11.64 kB │ gzip: 4.02 kB │ brotli: 3.44 kB dist/assets/index-8f3a1c.js 44.08 kB │ gzip: 15.71 kB │ brotli: 14.93 kB dist/assets/reports-5a3f18.js 18.22 kB │ gzip: 6.44 kB │ brotli: 5.71 kB dist/assets/editor-7e1b93.js 24.36 kB │ gzip: 8.12 kB │ brotli: 7.05 kB dist/assets/planner.worker-c40d7e.js 9.11 kB │ gzip: 3.28 kB dist/assets/ca-1f8a06.js 1.92 kB │ gzip: 0.74 kB dist/assets/en-4d2c77.js 1.88 kB │ gzip: 0.71 kB ✓ built in 1.42s
The first thing to learn to read in that output is what is and what is not on the critical path. The first three JavaScript files (index, model, vitals) are requested by the HTML: they are the critical path. reports, editor, planner.worker, ca and en do not appear in the HTML: they are chunks that will only be downloaded when somebody asks for them, and that is section 12's doing.
And the generated index.html. This is the starting point:
<!-- index.html — before (source) -->
<link rel="stylesheet" href="/css/styles.css">
<script type="module" src="/js/app.js"></script>And this is what Vite writes into dist/index.html:
<!-- dist/index.html — generated -->
<link rel="stylesheet" crossorigin href="/assets/styles-4c9e21.css">
<script type="module" crossorigin src="/assets/index-8f3a1c.js"></script>
<link rel="modulepreload" crossorigin href="/assets/model-2b7e40.js">
<link rel="modulepreload" crossorigin href="/assets/vitals-9c1d5a.js">Notice what it did without our asking: as well as replacing the paths with the hashed versions, it added two <link rel="modulepreload"> tags. That eliminates the cascade: the browser discovers the three JavaScript files while parsing the <head>, and requests them in parallel, without waiting to parse index-8f3a1c.js in order to discover that it needs model-2b7e40.js. It is section 14, applied automatically.
The result for row 10, measured with Slow 4G in incognito:
| Step | JS before the 1st card | JS requests | LCP |
|---|---|---|---|
| Baseline: 28 raw ES modules | 214 kB | 28 | 4.1 s |
Bundled without minification (build.minify: false) |
209 kB | 2 | 3.2 s |
| With minification (section 10) | 118 kB | 2 | 2.8 s |
| With tree shaking (section 11) | 104 kB | 2 | 2.7 s |
| With code splitting (section 12) | 58.3 kB | 3 | 2.4 s |
Read it carefully, because every row teaches something different. Bundling without minifying barely saves bytes (5 kB) but cuts almost a second: what was killing the LCP was not the weight, it was the cascade of 28 requests across four levels. Minification is what does save bytes, almost half of them. And code splitting removes another 46 kB, which is what the Coverage tab had flagged as "downloaded and unused".
A workflow change worth accepting explicitly, because it has a cost: from now on, the application is no longer opened by double-clicking index.html. There is a build step. In development you use npm run dev (which serves native, unbundled modules with instant reloading) and in production npm run build + npm run preview. We will come back to this cost in section 23.
- Minification
To minify is to rewrite the code so it takes up less space without changing what it does. Vite does it by default with esbuild. Specifically:
| Transformation | Example |
|---|---|
| Removing whitespace and line breaks | The whole file in a few lines |
| Removing comments | Your JSDoc never reaches the user |
| Shortening local names | const openHours → const a |
| Simplifying expressions | if (x) { return 1 } else { return 2 } → return x?1:2 |
| Removing unreachable code | Whatever follows a return |
| Constant folding | 60 * 1000 → 60000 |
A real fragment, before and after:
// Before: js/model/board.js (source)
/**
* Board summary. It is recomputed only if the board has changed
* or if it is requested for a different date.
*/
summary(today = TODAY) {
const c = this.#summaryCache;
if (c !== null && c.version === this.#version && c.today === today) {
return c.value;
}
const value = this.#computeSummary(today);
this.#summaryCache = { version: this.#version, today, value };
return value;
}// After: assets/model-2b7e40.js (minified)
summary(t=g){const e=this.#c;if(e!==null&&e.v===this.#v&&e.h===t)return e.r;const s=this.#p(t);return this.#c={v:this.#v,h:t,r:s},s}Three important things about this:
Exported names are not shortened (except with advanced configuration), because another module can import them by name. Only locals are shortened, and that is where most of the text is.
Minifying is not obfuscating. With source maps enabled, DevTools shows you the original code while debugging. Minification is not a security measure and must not be used as one.
Compressing and minifying are different things and they add up. Minifying reduces the text; compressing (section 19) reduces the bytes that travel over the wire. The 44.08 kB of the minified index travel as 14.93 kB with Brotli.
| State of the input file | Size |
|---|---|
| Source, 28 files | 214 kB |
| Bundled without minification | 209 kB |
| Bundled and minified | 118 kB |
| Bundled, minified and tree-shaken | 104 kB |
| Bundled, minified, split | 58.3 kB |
| …and transferred with Brotli | 19.4 kB |
From 214 kB to 19.4 kB over the wire. And not a line of source code has changed yet.
- Tree shaking: why ES modules make it possible
In 05-04 we said: "tree shaking and bundlers will be covered in 09-05". The moment has come.
Shaking the tree is removing from the final bundle the exports nobody imports. The name comes from the image of shaking a tree so the dead leaves fall.
The interesting part is why it can be done, and the answer connects with something 05-04 explained for another reason: the imports and exports of ES modules are static. They must be at the top level, they cannot go inside an if or a function, and their names are literals. That restriction, which at the time may have looked like a nuisance, is exactly what allows a tool to know for certain, without running anything, which exports are used and which are not.
With CommonJS that is impossible:
// CommonJS: the tool cannot know what is imported until it runs
const name = condition ? 'readableDate' : 'readableHours';
const fn = require('./util/format.js')[name]; // ✗ statically undecidable// ESM: the names are literals and at the top level. Decidable
import { readableDate } from './util/format.js'; // ✓ only this is keptThe concrete case in Nómada Tasks is util/format.js, which since 07-06 creates nine Intl formatters:
// js/util/format.js
const LOCALE = 'en-GB';
const LONG_DATE = new Intl.DateTimeFormat(LOCALE, { dateStyle: 'long' });
const SHORT_DATE = new Intl.DateTimeFormat(LOCALE, { day: 'numeric', month: 'short' });
const DATE_TIME = new Intl.DateTimeFormat(LOCALE, { dateStyle: 'medium', timeStyle: 'short' });
const RELATIVE = new Intl.RelativeTimeFormat(LOCALE, { numeric: 'auto' });
const NUMBER = new Intl.NumberFormat(LOCALE, { maximumFractionDigits: 1 });
const HOURS = new Intl.NumberFormat(LOCALE, { style: 'unit', unit: 'hour', unitDisplay: 'long' });
const PERCENT = new Intl.NumberFormat(LOCALE, { style: 'percent', maximumFractionDigits: 0 });
const LIST = new Intl.ListFormat(LOCALE, { style: 'long', type: 'conjunction' });
const COLLATOR = new Intl.Collator(LOCALE, { sensitivity: 'base', numeric: true });
export function readableDate(iso) { /* uses LONG_DATE */ }
export function shortDate(iso) { /* uses SHORT_DATE */ }
export function dateAndTime(iso) { /* uses DATE_TIME */ }
export function readableDays(days) { /* uses RELATIVE */ }
export function number(n) { /* uses NUMBER */ }
export function readableHours(n) { /* uses HOURS */ }
export function percentage(n) { /* uses PERCENT */ }
export function formatList(items) { /* uses LIST */ }
export function compareText(a, b) { /* uses COLLATOR */ }Of those nine functions, the critical path uses three: shortDate in .task__meta, readableHours in the column counter and compareText in the sort by title. The other six are used by the report and the exporter, which are no longer on the critical path. Tree shaking removes those six functions and their formatters, and that is a good part of the 14 kB lost at that step.
Now, the four conditions for tree shaking to actually work, because it fails far more often than people think:
1 · No wildcard imports where you can avoid them.
// ✗ Prevents shaking: it asks for the whole object
import * as format from './util/format.js';
element.textContent = format.shortDate(t.dueDate);
// ✓ Named import: the tool knows exactly what is used
import { shortDate } from './util/format.js';In practice, Rollup is smart enough to shake many import * cases when the usage is analyzable, but it only takes passing format to another function for it to have to keep the whole thing. Named imports never fail.
2 · Beware of module side effects. If a module does something when imported —registering a handler, mutating a global, creating an expensive object— the bundler cannot remove it even if you use none of its exports, because it does not know whether that effect matters.
// ✗ Side effect on import: this module can never be removed
document.body.classList.add('with-js');
export function nothing() {}The way to declare that your modules are clean is the sideEffects field in package.json:
That says: "all my code is side-effect-free except the CSS files and the entry point". Without that declaration, many tools assume the worst and keep too much.
3 · The library has to be published as ESM. A dependency that only ships CommonJS cannot be shaken. web-vitals publishes ESM, which is why its 2.6 kB are only what you use of it. It is a real criterion when choosing dependencies.
4 · Dead code has to be genuinely unreachable. If a function is used in a branch that only runs in development, it is still referenced. That is what build-time constants are for:
// Vite replaces import.meta.env.DEV with `false` at build time,
// and then the minifier removes the whole block as dead code
if (import.meta.env.DEV) {
const { instrument } = await import('./util/measure.js');
instrument(view);
}And a way to check that all this works instead of assuming it: the rollup-plugin-visualizer report we configured in section 8.
It opens a treemap where the size of each rectangle is the module's weight in the final bundle. It is the tool that answers "why does my bundle weigh 140 kB?", and the answer is usually a dependency you did not know you were dragging along.
- Code splitting with dynamic
import()
import()In 05-04 you met dynamic import(): it looks like a function call, it returns a promise, it accepts variable paths and it can go inside an if. There it was presented as syntax; here it is the tool that takes 46 kB off the critical path.
The rule for deciding what to split comes straight from the Coverage table in section 5:
Split whatever satisfies all three conditions: (1) it is heavy, (2) it is not used on the first screen and (3) it is triggered by a concrete user action. If any of the three is missing, do not split it: you will add a wait without saving anything relevant.
In Nómada Tasks there are exactly three candidates.
12.1 The planning report and its Web Worker
The reports module weighs 18.2 kB minified and drags in the worker from 09-02. Marta uses it once a quarter.
// js/view/controller.js
// ✗ Before: static import. 18 kB and the worker on the critical path
// import { Planner } from '../planning/planner-client.js';
let planner = null;
$('#generate-report').addEventListener('click', async () => {
const button = $('#generate-report');
button.disabled = true;
button.textContent = 'Loading…';
try {
// ✓ Downloaded the FIRST time somebody presses. After that it is already in memory
if (planner === null) {
const { Planner } = await import('../planning/planner-client.js');
planner = new Planner();
}
button.textContent = 'Calculating…';
const report = await planner.report([...board].map((t) => t.toJSON()));
showReport(report);
} catch (error) {
showError(`The report could not be generated: ${error.message}`);
} finally {
button.disabled = false;
button.textContent = 'Generate report';
}
}, { signal: controller.signal });Notice a detail Vite resolves by itself: the Planner creates its worker with new Worker(new URL('./planner.worker.js', import.meta.url), { type: 'module' }). That pattern, presented in 09-02 as "mandatory if you use a bundler", is exactly what allows Vite to discover the worker, compile it as a separate hashed chunk (planner.worker-c40d7e.js) and rewrite the URL. With a string path, the worker would not have been included in the build and would give a 404 in production.
12.2 The rich description editor
It weighs 24.4 kB and only appears when Iván presses "Edit description" on a card. It is the textbook case.
// js/view/form.js
import { $ } from './dom.js';
/** Loads the editor the first time it is needed. Caches the promise, not the module. */
const loadEditor = (() => {
let promise = null;
return () => (promise ??= import('./description-editor.js'));
})();
$('#new-task').addEventListener('click', async (event) => {
if (event.target.closest('[data-action="edit-description"]') === null) return;
const box = $('#description');
box.classList.add('loading');
try {
const { mountEditor } = await loadEditor();
mountEditor(box, { onSave: saveDescription });
} catch (error) {
// Graceful degradation: the plain <textarea> still works
console.warn('The rich editor could not be loaded', error);
box.focus();
} finally {
box.classList.remove('loading');
}
}, { signal: controller.signal });Two interesting decisions in that fragment:
- The promise is cached, not the module.
promise ??= import(...)guarantees that two quick clicks do not launch two downloads: the second receives the same in-flight promise. It is a pattern worth automating. - Failure has a dignified exit. If the chunk does not download —network down, a deployment mid-flight— the plain
<textarea>is still there and the user can type. That is progressive enhancement, the same principle as 07-06.
12.3 The translations
Each language weighs about 1.9 kB. Downloading all three to use one is throwing away two thirds, and it is the case where import()'s variable path shines:
// js/i18n/index.js
const LANGUAGES = new Set(['es', 'ca', 'en']);
const loaded = new Map();
/**
* Loads a language's texts on demand.
* The partial template literal is ESSENTIAL: it tells the bundler
* which files are candidates, which is why it emits ca-*.js and en-*.js as chunks.
*/
export async function loadLanguage(code) {
if (!LANGUAGES.has(code)) code = 'en';
if (loaded.has(code)) return loaded.get(code);
const promise = import(`./texts/${code}.js`).then((m) => m.texts);
loaded.set(code, promise);
return promise;
}The detail to understand is the one in the comment: a bare import(variable) is undecidable for the bundler, which would not know what to include and would fail at run time. import(\./texts/${code}.js`)`, with the fixed part visible in the literal, lets it compile every file matching the pattern as an independent chunk, and pick one at run time. It is a restriction you have to know about.
Besides, English resolves with no download because it is the default language and its texts are in the main bundle. Only Marta, whose browser is set to Catalan, downloads an extra 1.9 kB.
The result of the three splits:
| Chunk | Size | When it is downloaded | % of visits that request it |
|---|---|---|---|
index-8f3a1c.js |
44.1 kB | Always | 100% |
model-2b7e40.js |
11.6 kB | Always | 100% |
vitals-9c1d5a.js |
2.6 kB | Always | 100% |
reports-5a3f18.js + worker |
27.3 kB | On pressing "Generate report" | 4% |
editor-7e1b93.js |
24.4 kB | On editing a description | 11% |
ca-1f8a06.js / en-4d2c77.js |
1.9 kB | Depending on the language | 22% |
58.3 kB for 100% of visits, instead of 104 kB. And the saving is not spread evenly: whoever only consults the board —most people— downloads 58 kB and that is it.
And a word against over-enthusiasm, because over-splitting is a real mistake: every import() is a round trip. If you split a 3 kB module that is needed half a second after loading, you have traded 3 kB for 280 ms of latency on Slow 4G. That is a bad deal. Split big, late chunks, not everything that can be split.
- The loading pattern: indicator, cancellation and failure
Loading on demand introduces something that did not exist before: a visible wait in the middle of an interaction. It has to be treated like any asynchronous operation (05-06, 07-03), and it is worth having a single helper so as not to repeat the same wiring.
// js/util/lazy.js
/**
* Wraps a dynamic import() with caching, an indicator and retries.
*
* @param {() => Promise<any>} loader Function that performs the import().
* @param {object} options
* @param {HTMLElement} [options.indicator] Element to give the 'loading' class to.
* @param {number} [options.retries] Retries on network failure.
* @returns {() => Promise<any>}
*/
export function lazy(loader, { indicator = null, retries = 1 } = {}) {
let promise = null;
return async function load() {
if (promise !== null) return promise; // already loaded or in flight
indicator?.classList.add('loading');
indicator?.setAttribute('aria-busy', 'true'); // accessibility: something is under way
promise = (async () => {
for (let attempt = 0; ; attempt += 1) {
try {
return await loader();
} catch (error) {
if (attempt >= retries) {
promise = null; // ← allow retrying again later
throw error;
}
// Simple backoff, like withRetries in 07-03
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
}
}
})();
try {
return await promise;
} finally {
indicator?.classList.remove('loading');
indicator?.removeAttribute('aria-busy');
}
};
}// Usage
const loadReports = lazy(
() => import('../planning/planner-client.js'),
{ indicator: $('#report-panel'), retries: 2 }
);Three details that separate a correct lazy load from one that causes trouble in production:
When it fails for good, the cached promise must be cleared. If you keep it, every subsequent attempt will receive the same error forever, even after the network comes back. It is a subtle and very common bug.
A new deployment invalidates the hashes. If a user has had the tab open for two hours and you deploy a new version, the editor-7e1b93.js their index tries to request may no longer exist on the server. That import() will fail with a network error that looks inexplicable. There are two mitigations: keeping the old files on the server for a few days (the simplest, and what most people do), and detecting the failure to offer a reload:
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault();
showNotice('There is a new version of Nómada Tasks. Reload to continue.', {
action: () => location.reload()
});
});The indicator must be accessible. aria-busy="true" tells screen readers that the region is updating; a CSS class with an animation says nothing at all to anyone who cannot see the screen. And if the wait can exceed a second (09-01), you also need explicit text.
- Preloading:
preload, modulepreload, prefetch, preconnect, dns-prefetch
preload, modulepreload, prefetch, preconnect, dns-prefetchThe browser is fairly good at prioritizing, but it can only prioritize what it knows about. Resource hints exist to tell it about things before it discovers them for itself.
| Hint | What it does | Priority | When to use it | Risk of overuse |
|---|---|---|---|---|
<link rel="preconnect"> |
Opens the connection (DNS + TCP + TLS) without downloading anything | — | An origin you will definitely request from shortly | Each idle connection consumes resources. 2–3 maximum |
<link rel="dns-prefetch"> |
Resolves DNS only | — | Likely but not certain origins | Almost none; it is very cheap |
<link rel="preload"> |
Downloads now, at high priority, without executing | High | A critical resource the browser would discover late (fonts, LCP image) | It competes with what really is critical and can worsen LCP |
<link rel="modulepreload"> |
Downloads and parses an ES module, without running it | High | Chunks the entry module is going to import | Parsing costs CPU too |
<link rel="prefetch"> |
Downloads at minimum priority, for the next navigation | The lowest | What the user will probably request next | Spends data that may never be used |
The difference between preload and prefetch is the most commonly confused and easy to remember: preload is "I need it for this screen, now"; prefetch is "I may need it later, when you have nothing better to do".
Applied to Nómada Tasks:
<head>
<!-- 1 · The tasks API: connection opened before app.js makes its first fetch -->
<link rel="preconnect" href="https://api.tallernomada.example" crossorigin>
<link rel="dns-prefetch" href="https://api.tallernomada.example">
<!-- 2 · The font: the browser does not discover it until it parses the CSS. Without this
it arrives late and causes the text shift you will see in section 17.
`crossorigin` is MANDATORY on fonts, even same-origin ones -->
<link rel="preload" href="/assets/inter-subset-3d9a17.woff2"
as="font" type="font/woff2" crossorigin>
<!-- 3 · Generated by Vite: the chunks index-*.js is going to import -->
<link rel="modulepreload" crossorigin href="/assets/model-2b7e40.js">
<link rel="modulepreload" crossorigin href="/assets/vitals-9c1d5a.js">
<link rel="stylesheet" crossorigin href="/assets/styles-4c9e21.css">
<script type="module" crossorigin src="/assets/index-8f3a1c.js"></script>
</head>And the prefetch, which in Nómada Tasks is better done from JavaScript, when the browser is idle (09-02):
// js/app.js — preload the editor when there is nothing better to do
requestIdleCallback(() => {
// The editor is used by 11% of visits, but when it is used, it is used early
import('./view/description-editor.js');
}, { timeout: 5000 });That import() with no await and no use of the result is idiomatic: it exists solely to get the chunk into the HTTP cache. When the user presses "Edit description", the module will already be there and loading will be instantaneous.
And the three rules that stop hints doing harm:
- Preload little. If you preload five things, none of them is a priority.
preloadworks because it displaces resources in the queue; preloading everything is preloading nothing. - Never preload something you are not going to use on this screen. The browser warns in the console ("The resource was preloaded but not used within a few seconds") and rightly so: you have spent critical-path bandwidth.
crossoriginon fonts is mandatory. Fonts are always downloaded in anonymous mode, so apreloadwithoutcrossorigincauses two downloads of the same font. It is the most common mistake withpreloadand it doubles the weight of the most expensive resource on the page.
- Lazy loading images
Nómada Tasks has few images —the workshop logo and each assignee's avatar— but enough to illustrate the four attributes that matter.
<!-- ✗ Before: no dimensions, no priority, everything eager -->
<img src="/img/workshop-logo.png" alt="Taller Nómada">
<img src="/img/avatar-ivan.png" alt="">
<!-- ✓ After -->
<img src="/assets/workshop-logo-a91c4e.webp"
alt="Taller Nómada"
width="180" height="48"
fetchpriority="high"
decoding="async">
<img src="/assets/avatar-ivan-6b2d05.webp"
alt=""
width="32" height="32"
loading="lazy"
decoding="async">What each attribute does:
| Attribute | What it does | When |
|---|---|---|
width / height |
Reserve the space before the image arrives | Always. It is half the CLS |
loading="lazy" |
Not downloaded until it approaches the visible area | Images below the fold |
loading="eager" |
Immediate download (the default) | The LCP image |
decoding="async" |
Decode off the main thread | Almost always |
fetchpriority="high" |
Raises its priority in the download queue | The LCP image, and only that one |
fetchpriority="low" |
Lowers it | Large decorative images |
Four warnings that avoid the typical mistakes:
loading="lazy" on the LCP image is shooting yourself in the foot. It deliberately delays the resource that defines your main metric. It is by far the most frequent misuse of this technique, and Lighthouse detects it and flags it.
width and height work even if the CSS changes the size. Putting width="180" height="48" does not fix the size if you have img { max-width: 100%; height: auto; }: modern browsers use those two numbers only to compute the aspect ratio and reserve the right space. That is exactly what CLS needs and it does not interfere with responsive design.
The format matters more than lazy loading. Converting the PNGs to WebP brought Nómada Tasks's two images down from 41 kB to 9.2 kB with no visible difference. AVIF would have brought them to 6.8 kB with less compatibility. Before arguing about lazy, check the format.
For large images, use srcset and sizes. Serving a 1,600 px wide image to a 360 px phone is throwing away three quarters of the bytes:
<img src="/assets/cover-800.webp"
srcset="/assets/cover-400.webp 400w,
/assets/cover-800.webp 800w,
/assets/cover-1600.webp 1600w"
sizes="(max-width: 640px) 100vw, 800px"
width="1600" height="900"
alt="View of the workshop" fetchpriority="high" decoding="async">
- Lazy loading components with
IntersectionObserver
IntersectionObserverloading="lazy" only exists for <img> and <iframe>. For components —a map, a chart, a heavy panel— you need the IntersectionObserver from 07-06, combined with the import() from section 12.
In Nómada Tasks there is one clear candidate: the workload-by-assignee panel, which draws a bar chart and lives at the bottom of the sidebar, almost always off screen.
// js/view/lazy-visible.js
/**
* Mounts a component the first time its container approaches the visible area.
*
* @param {HTMLElement} container
* @param {() => Promise<{mount: Function}>} loader
* @param {object} [options]
* @returns {() => void} cleanup function (09-03)
*/
export function whenNear(container, loader, { margin = '300px' } = {}) {
const observer = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting) return;
observer.disconnect(); // once only: no reloading while scrolling
container.setAttribute('aria-busy', 'true');
try {
const { mount } = await loader();
mount(container);
} catch (error) {
container.textContent = 'The workload panel could not be loaded.';
console.warn(error);
} finally {
container.removeAttribute('aria-busy');
}
}, { rootMargin: margin }); // start BEFORE it is visible (07-06)
observer.observe(container);
return () => observer.disconnect(); // for the view's destroy()
}// js/app.js
import { whenNear } from './view/lazy-visible.js';
const cleanUpPanel = whenNear(
$('#workload-panel'),
() => import('./view/workload-panel.js')
);Two conditions for this not to make the experience worse, and both come from the previous lesson:
The space must be reserved. If #workload-panel is 0 px tall until the component mounts, mounting it will push everything down and add CLS. The solution is to give it in the CSS the height it is going to have, or an aspect-ratio:
The rootMargin must be generous. With 300px, the download starts when the panel is three hundred pixels from appearing, so by the time the user gets there it is already mounted. With no margin, they would see the empty gap for half a second.
- Fonts:
font-display, subsetting, preloading and fallback metrics
font-display, subsetting, preloading and fallback metricsWeb fonts are, by far, the most underestimated resource. In Nómada Tasks, a single font weighs 94 kB: more than all the critical-path JavaScript after optimizing it. And it is also the main source of CLS.
The problem has two faces that must be kept separate:
- FOIT (flash of invisible text): while the font downloads, the browser hides the text. The page looks empty even though the HTML is ready. It kills FCP and LCP.
- FOUT (flash of unstyled text): the text is shown in a fallback font and swapped when the web font arrives. It does not kill LCP, but if the two fonts have different metrics, the text reflows and that is CLS.
17.1 font-display
@font-face {
font-family: 'Inter';
src: url('/assets/inter-subset-3d9a17.woff2') format('woff2');
font-weight: 400 700; /* variable font: one file, all weights */
font-style: normal;
font-display: swap; /* ← the key decision */
}font-display value |
Block period | Behavior |
|---|---|---|
auto |
~3 s | Whatever the browser decides. Usually a long FOIT |
block |
~3 s | Invisible text for up to 3 s. Avoid it |
swap |
0 ms | Immediate fallback, swap on arrival. FOUT, not FOIT |
fallback |
~100 ms | Brief invisibility; if it takes more than 3 s, the fallback stays |
optional |
~100 ms | Like fallback, but the browser may never use the web font. Zero CLS |
swap is the sensible default: it guarantees the text is visible from the first moment. optional is the choice if CLS matters more to you than the typography —the browser decides, and on slow connections it simply uses the fallback, with zero shift.
17.2 Subsetting
A complete font includes thousands of glyphs: Greek, Cyrillic, Vietnamese, mathematical symbols. Nómada Tasks writes in Spanish, Catalan and English: it needs basic and extended Latin, and little else.
# The standard tool (Python), from the fonttools project
pip install fonttools brotli
pyftsubset inter-variable.ttf \
--output-file=inter-subset.woff2 \
--flavor=woff2 \
--layout-features='kern,liga' \
--unicodes='U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212'| Font | Size | Glyphs |
|---|---|---|
inter-variable.ttf (original) |
312 kB | 2,548 |
inter-variable.woff2 |
94 kB | 2,548 |
inter-subset.woff2 |
21.4 kB | 382 |
From 94 kB to 21.4 kB. It is the single biggest saving of the whole lesson, and it does not touch a line of code. Three rules: always use WOFF2 (the .ttf, .eot and .woff formats have been surplus for years), use variable fonts when you need several weights, and check that the subset includes what you write: the Catalan names with ŀ, the typographic quotes and the · separator symbol Nómada Tasks uses.
17.3 Preloading
The browser does not discover the font until it has downloaded the CSS and worked out which elements use it. That is two round trips of delay. That is why the critical font is preloaded, with the crossorigin from section 14:
<link rel="preload" href="/assets/inter-subset-3d9a17.woff2"
as="font" type="font/woff2" crossorigin>Preload one single font: the one used for the main text. Preloading four variants is a textbook case of a counterproductive hint.
17.4 Fallback metrics: the remaining CLS
Even with swap and preloading, the swap shift remains: the fallback and the web font have different widths and line heights, so swapping them reflows the text. The modern solution is to declare an adjusted fallback font with the real one's metrics:
/* Adjusted fallback: the system font, warped to MEASURE the same as Inter */
@font-face {
font-family: 'Inter fallback';
src: local('Arial'), local('Helvetica Neue'), local('sans-serif');
size-adjust: 107%; /* Arial is narrower: widen it by 7% */
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'Inter fallback', system-ui, sans-serif;
}With those four properties, the fallback text occupies exactly the same boxes as the final one, and the swap stops moving anything: the FOUT still exists visually (the letterforms change) but the CLS drops to zero. The values of size-adjust and friends are computed by comparing the two fonts' metrics; there are tools that generate them, and adjusting them by eye is not reasonable.
- Closing row 3: where the CLS of 0.21 came from
CLS measures how much the content moves without the user causing it. In 09-01 we noted 0.21 and in 09-04 we learned to see it with Layout Shift Regions in the Rendering panel. Recording with the Performance panel and looking at the layout-shift entries, the 0.21 breaks down like this:
| Source of the shift | Contribution | When it happens |
|---|---|---|
| The 600 cards are inserted when the data arrives and push the footer down | 0.13 | ~3.8 s |
| The text reflows when the web font arrives | 0.05 | ~2.9 s |
The logo without width/height reserves 0 px and then 48 px |
0.03 | ~1.3 s |
| Total | 0.21 |
And the three fixes, each from a different section of this lesson:
The card shift (0.13 → 0.00). It is not fixed by loading earlier, but by reserving the space: a skeleton with exactly the height the columns are going to have. Since the list is virtualized (09-04) and each card is 116 px, the height is known in advance.
.column { min-height: 640px; } /* the space exists from the first paint */
.task-list { min-height: 640px; }
.task--skeleton {
height: 108px;
margin-bottom: 8px;
background: linear-gradient(90deg, var(--gray-light) 25%, var(--gray-lighter) 50%,
var(--gray-light) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer { to { background-position: -200% 0; } } /* only background-position */
@media (prefers-reduced-motion: reduce) {
.task--skeleton { animation: none; }
}A note that connects with 09-01: a skeleton does not count as content for LCP. It helps CLS and perception, not the metric. Do not add it believing it improves LCP, because it does not.
The font shift (0.05 → 0.00). The adjusted fallback with size-adjust and ascent-override from section 17.4.
The logo shift (0.03 → 0.00). width="180" height="48" on the <img>, from section 15.
| Measurement | Before | After |
|---|---|---|
| CLS (mobile Lighthouse) | 0.21 | 0.02 |
The remaining 0.02 is a tiny shift in the summary bar, which goes from one line to two when the number of tasks exceeds three digits. It is below the 0.1 threshold and fixing it would require fixing the height of an element whose content is genuinely variable. It is a conscious decision not to chase zero, in line with 09-01: the last points cost a lot and add nothing perceptible.
- Compression: Gzip and Brotli
Minifying reduces the text; compressing reduces the bytes that travel. They add up, and compression is the server's responsibility, not the bundler's.
| Algorithm | Compatibility | Ratio on JS | Cost to compress |
|---|---|---|---|
| None | — | 1× | 0 |
| Gzip | Universal for 20 years | ~3.5× | Low |
Brotli (br) |
All modern browsers | ~4.2× | Medium (high if done on the fly) |
Zstandard (zstd) |
Emerging | ~4.3× | Low |
The browser announces what it accepts and the server chooses:
Accept-Encoding: gzip, deflate, br, zstd ← what the browser sends Content-Encoding: br ← what the server answers
Over the built Nómada Tasks:
| File | Uncompressed | Gzip | Brotli |
|---|---|---|---|
index-8f3a1c.js |
44.08 kB | 15.71 kB | 14.93 kB |
model-2b7e40.js |
11.64 kB | 4.02 kB | 3.44 kB |
vitals-9c1d5a.js |
2.61 kB | 1.18 kB | 1.02 kB |
styles-4c9e21.css |
17.90 kB | 3.91 kB | 3.22 kB |
| Critical path total | 76.23 kB | 24.82 kB | 22.61 kB |
Three things to know:
Compress at build time, not on the fly. Brotli at maximum level (11) is slow to compress and fast to decompress. Compressing every response on the fly forces you to use a low level; generating the .br files during the build gives you level 11 for free. Almost any server knows how to serve a precompressed file.js.br if it exists.
Do not compress what is already compressed. WebP, AVIF, WOFF2, PNG, JPEG and MP4 already carry their own compression. Recompressing them wastes CPU and sometimes increases the size.
Check that compression is on. It is the most common and most invisible deployment failure: in the Network panel, if the transferred size and resource size columns match, there is no compression. In the Nómada Tasks baseline, the 214 kB of JavaScript appeared identically in both columns. That single observation was worth 150 kB.
- HTTP caching, hashing and the service worker from 07-05
The fastest visit is the one that downloads nothing. HTTP caching achieves that, and the hashing from section 8 is what makes it safe.
The two headers that govern the matter:
| Header | What it does |
|---|---|
Cache-Control: max-age=N |
The browser can use the local copy for N seconds without asking |
Cache-Control: no-cache |
It may store it, but it must revalidate before using it |
Cache-Control: immutable |
Promises the content will never change: not even revalidate on reload |
ETag: "abc123" |
A fingerprint of the content; the browser sends it back in If-None-Match |
Last-Modified |
A date; the browser sends it back in If-Modified-Since |
With ETag, when the max-age expires the browser asks and the server can answer 304 Not Modified with no body: you save the weight, but not the round trip. With immutable, there is not even a trip.
And here is the deep reason for hashing. index-8f3a1c.js carries a digest of the content in its name. If the content changes, the name changes. That allows a strategy with no compromises:
# Hashed assets: cache for a year, never revalidate
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# The HTML: NEVER cache it. It is what points at the new names
location = /index.html {
add_header Cache-Control "no-cache";
}
# The service worker: never cache it (the literal warning from 07-05)
location = /sw.js {
add_header Cache-Control "no-cache";
}flowchart TD
A["You deploy a new version"] --> B["index.html changes<br/>(no-cache: always requested)"]
B --> C{"Which assets<br/>does it reference?"}
C -->|"index-8f3a1c.js<br/>(unchanged)"| D["Already in the cache<br/><b>0 bytes</b>"]
C -->|"index-b7d20f.js<br/>(new name)"| E["Only what changed<br/>is downloaded"]
D --> F["Almost instant load"]
E --> F
That is what makes the manualChunks from section 8 valuable: if you only touch the view, model-2b7e40.js keeps its hash and users do not download it again.
20.1 The service worker from 07-05, now that there is a build step
There is a real conflict here that has to be resolved, and it is the kind of thing that breaks deployments. The sw.js from 07-05 precached a hand-written list:
// ✗ sw.js — this list no longer exists after building
const SHELL_ASSETS = [
'/', '/index.html', '/css/styles.css',
'/js/app.js', '/js/model/task.js', '/js/model/board.js',
// …25 more paths that are no longer served…
];After npm run build none of those paths exists: they are now /assets/index-8f3a1c.js and friends. The cache.addAll would fail entirely, because addAll is atomic: if one request fails, nothing is stored. The service worker would not install and the application would lose offline mode without anyone noticing until a user got on the underground.
The solution is to generate the list at build time. With a small plugin of your own, which also illustrates how the tools hook together:
// scripts/precache-plugin.mjs
import { writeFileSync } from 'node:fs';
/** Writes dist/precache.json with the generated assets and their hashes. */
export function generatePrecache() {
return {
name: 'generate-precache',
generateBundle(options, bundle) {
const paths = ['/', '/index.html', '/offline.html', '/manifest.json'];
for (const [name, file] of Object.entries(bundle)) {
// Only what is needed to start: no lazy chunks, no source maps
if (name.endsWith('.map')) continue;
if (file.isDynamicEntry) continue;
paths.push(`/${name}`);
}
this.emitFile({
type: 'asset',
fileName: 'precache.json',
source: JSON.stringify({ version: Date.now(), paths }, null, 2)
});
}
};
}// sw.js — reads the generated list instead of hard-coding it
self.addEventListener('install', (event) => {
event.waitUntil((async () => {
const response = await fetch('/precache.json', { cache: 'no-store' });
const { version, paths } = await response.json();
const cache = await caches.open(`nomada-shell-${version}`);
await cache.addAll(paths);
})());
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const response = await fetch('/precache.json', { cache: 'no-store' });
const { version } = await response.json();
// Delete the caches of previous versions (07-05)
const names = await caches.keys();
await Promise.all(names
.filter((n) => n.startsWith('nomada-') && !n.endsWith(String(version)))
.map((n) => caches.delete(n)));
await self.clients.claim();
})());
});Three deployment warnings worth their weight in gold:
sw.jswithCache-Control: no-cache, as 07-05 already said. If a CDN serves an oldsw.jsfor hours, your users are frozen and there is nothing you can do from the client.- Do not precache the lazy chunks. The
reports-*.jsthat only 4% of visits request must not be downloaded when the service worker installs: that would be exactly the problem we have just solved, moved elsewhere. It is cached on request, withstaleWhileRevalidate(07-05). - Keep the previous deployment's files around for a few days. It is section 13's mitigation for the
import()calls that fail in tabs left open during a deployment.
- The performance budget in continuous integration
In 09-01 it was said that quantity budgets are far more stable than time budgets, because they do not depend on runner noise, and that they would be covered here. Let us set them up.
Level 1: the byte budget, checked with a script of your own over the build output.
// scripts/budget.mjs
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { brotliCompressSync } from 'node:zlib';
// The budget is row 10 of the 09-01 baseline
const BUDGET = {
initialJsKB: 80, // ≤ 80 kB of uncompressed JS on the critical path
jsRequests: 5, // ≤ 5 requests
cssKB: 25,
brotliTotalKB: 30
};
const html = readFileSync('dist/index.html', 'utf8');
// The files the HTML requests directly = the critical path
const critical = [...html.matchAll(/(?:src|href)="\/(assets\/[^"]+)"/g)].map((m) => m[1]);
const js = critical.filter((f) => f.endsWith('.js'));
const css = critical.filter((f) => f.endsWith('.css'));
const bytes = (f) => statSync(join('dist', f)).size;
const brotli = (f) => brotliCompressSync(readFileSync(join('dist', f))).length;
const jsKB = js.reduce((s, f) => s + bytes(f), 0) / 1024;
const cssKB = css.reduce((s, f) => s + bytes(f), 0) / 1024;
const brotliKB = critical.reduce((s, f) => s + brotli(f), 0) / 1024;
console.table(critical.map((f) => ({
File: f,
kB: (bytes(f) / 1024).toFixed(2),
'kB (br)': (brotli(f) / 1024).toFixed(2)
})));
const failures = [];
if (jsKB > BUDGET.initialJsKB) failures.push(`Initial JS ${jsKB.toFixed(1)} kB > ${BUDGET.initialJsKB}`);
if (js.length > BUDGET.jsRequests) failures.push(`${js.length} JS requests > ${BUDGET.jsRequests}`);
if (cssKB > BUDGET.cssKB) failures.push(`CSS ${cssKB.toFixed(1)} kB > ${BUDGET.cssKB}`);
if (brotliKB > BUDGET.brotliTotalKB) failures.push(`Brotli total ${brotliKB.toFixed(1)} kB > ${BUDGET.brotliTotalKB}`);
if (failures.length > 0) {
console.error('\n✗ Performance budget exceeded:');
for (const f of failures) console.error(` · ${f}`);
process.exit(1); // ← breaks the build, like a failing Jest test
}
console.log(`\n✓ Budget met: ${jsKB.toFixed(1)} kB of JS in ${js.length} requests`);Real output over the build from section 9:
┌─────────┬──────────────────────────────┬───────┬─────────┐ │ (index) │ File │ kB │ kB (br) │ ├─────────┼──────────────────────────────┼───────┼─────────┤ │ 0 │ 'assets/styles-4c9e21.css' │ 17.90 │ 3.22 │ │ 1 │ 'assets/index-8f3a1c.js' │ 44.08 │ 14.93 │ │ 2 │ 'assets/model-2b7e40.js' │ 11.64 │ 3.44 │ │ 3 │ 'assets/vitals-9c1d5a.js' │ 2.61 │ 1.02 │ └─────────┴──────────────────────────────┴───────┴─────────┘ ✓ Budget met: 58.3 kB of JS in 3 requests
Level 2: Lighthouse CI, which you already configured in 09-01, with the assertions now tuned to the table's targets:
{
"ci": {
"collect": {
"url": ["http://localhost:4173/"],
"numberOfRuns": 3,
"startServerCommand": "npm run preview"
},
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"first-contentful-paint": ["warn", { "maxNumericValue": 1800 }],
"unused-javascript": ["warn", { "maxNumericValue": 20000 }],
"uses-text-compression": "error",
"unsized-images": "error",
"font-display": "error",
"uses-responsive-images": "off"
}
},
"upload": { "target": "temporary-public-storage" }
}
}Notice the four new assertions and why they are rule-based rather than time-based: uses-text-compression detects that somebody has turned Brotli off on the server, unsized-images detects an <img> with no dimensions —section 18's CLS— and font-display detects a @font-face without swap. They are binary and stable checks, exactly the ones worth setting as error.
And the complete workflow, following 08-06's cheap-things-first order:
# .github/workflows/ci.yml (fragment)
performance:
needs: [tests]
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: npm run budget # cheap, stable, breaks the build
- run: npx lhci autorun # expensive, somewhat noisy, thresholds with headroom
- uses: actions/upload-artifact@v4
with:
name: bundle-report
path: dist/bundle-report.htmlThe last step saves rollup-plugin-visualizer's treemap as an artifact of the run. When the budget fails three months from now because somebody added a charting library, that report will tell you which one in thirty seconds.
- The complete table: the ten rows, before and after
This is the closing of the contract 09-01 established. Same procedure —median of 15 runs after 5 warm-up runs, CPU 4×, Slow 4G, incognito, the same reference laptop, the same board of 600 tasks from a fixed seed.
| # | Measurement | Before | Target | After | Where it was solved |
|---|---|---|---|---|---|
| 1 | LCP (simulated mobile) | 4.1 s | ≤ 2.5 s | 1.9 s ✅ | 09-05 (8–11, 14, 17) |
| 2 | INP when typing in the search box | 480 ms | ≤ 200 ms | 42 ms ✅ | 09-02 (cache + debounce), 09-04 (render) |
| 3 | CLS | 0.21 | ≤ 0.1 | 0.02 ✅ | 09-05 (15, 17, 18) |
| 4 | Longest task at startup | 1,180 ms | ≤ 200 ms | 41 ms ✅ | 09-02 (inBatches) |
| 5 | Full render(), 600 tasks |
310 ms | ≤ 50 ms | 31 ms ✅ | 09-04 (read-then-write, fingerprint, virtualization) |
| 6 | summary() recalculations per filtering |
96 ms | ≤ 5 ms | 1.5 ms ✅ | 09-02 (version-counter cache) |
| 7 | Planning report | 940 ms | 0 ms on the main thread | 52 ms ⚠️ | 09-02 (Web Worker) |
| 8 | Memory retained after 200 filterings | +37.7 MB | ≈ 0 | +0.4 MB ✅ | 09-03 (AbortController, index purging) |
| 9 | DOM nodes in the document | 7,812 | ≤ 1,500 | 1,194 ✅ | 09-04 (VirtualList) |
| 10 | JS downloaded before the 1st card | 214 kB / 28 req. | ≤ 80 kB / ≤ 5 | 58.3 kB / 3 req. ✅ | 09-05 (8–12) |
And the breakdown of row 1, because it is the one that sums up this lesson's work:
| Step | Initial JS | Requests | LCP | CLS |
|---|---|---|---|---|
| Baseline | 214 kB | 28 | 4.10 s | 0.21 |
| Bundled with Vite | 209 kB | 2 | 3.20 s | 0.21 |
| Minification | 118 kB | 2 | 2.80 s | 0.21 |
| Tree shaking | 104 kB | 2 | 2.70 s | 0.21 |
| Code splitting | 58.3 kB | 3 | 2.40 s | 0.21 |
| Brotli compression on the server | 58.3 kB (19.4 kB transferred) | 3 | 2.15 s | 0.21 |
preconnect, font preload, modulepreload |
58.3 kB | 3 | 2.00 s | 0.21 |
| Font subset (94 → 21.4 kB) | 58.3 kB | 3 | 1.90 s | 0.21 |
| Sized images, skeleton, adjusted fallback | 58.3 kB | 3 | 1.90 s | 0.02 |
And the Network panel's summary bar, compared with 09-01's:
Before: 31 requests · 238 kB transferred · 512 kB resources · DCL 1.9 s · Load 4.3 s After: 9 requests · 57 kB transferred · 138 kB resources · DCL 0.9 s · Load 2.0 s
- What has not been solved and what it cost
Nine green ticks and one amber warning. The warning is worth looking at, and above all the bill.
Row 7 did not reach zero. The target was "0 ms on the main thread" and the result is 52 ms: 3.3 ms to serialize the outbound trip, about 45 for the return trip with the full report and a few more to receive it. Taking the computation off the main thread does not eliminate the cost of crossing the boundary, as 09-02 explained with structured cloning. It could be brought down further by using a transferable ArrayBuffer for the numeric data, but that would mean hand-serializing a structure that today is a readable object. Fifty-two milliseconds are one dropped frame, not a frozen interface. It is a conscious decision, not an oversight, and that is how it should be documented.
The remaining 0.02 of CLS is the summary bar going past three digits, and it is already explained in section 18: chasing zero would require fixing the height of something genuinely variable.
None of this is measured in the field. All these numbers are lab numbers, on a specific laptop with simulated throttling. The web-vitals setup you installed in 09-01 is still the only way to know what actually happens to Marta on her phone. The lab/field distinction from 09-01 does not disappear because the table has gone green.
And now the bill, which is the part almost nobody writes down:
| What was gained | What it cost |
|---|---|
| LCP 2.2 s faster | A build step: the application no longer opens by double-clicking index.html |
| 156 kB less on the critical path | Two dev dependencies and a vite.config.js that has to be understood |
| Render 10× faster | ~90 lines of VirtualList, with aria-setsize, broken Ctrl+F and a new failure mode |
| No memory leaks | A destroy() in every class and an AbortController you have to remember to use |
| Report that does not freeze the interface | A worker, a message protocol with ids and serialization to plain data |
| Safe eternal caching | Hashed names, and a sw.js that can no longer have a hand-written list |
| A policed budget | Two more CI jobs somebody will have to maintain |
All of that is real complexity, and not all of it was justified in advance. If Taller Nómada had six tasks instead of six hundred, almost none of this module would have been necessary: the render cost 1.4 ms, there were no perceptible leaks and 214 kB on a decent network is half a second. The discipline from 09-01 was not about optimizing everything, but about measuring first to know what was worth doing, and that discipline includes the opposite decision: not doing anything when the measurement says it is not needed.
If you had to keep only three things out of the fifteen you have done, the three with the best benefit-to-complexity ratio would be: fixing the layout thrashing (310 → 179 ms, half an hour of work, zero added complexity), bundling and minifying (4.1 → 2.8 s of LCP, one configuration file) and subsetting the font (72.6 kB less, one terminal command). All three are infrastructure or ordering changes, not architectural ones. Virtualization and the worker, which are the most impressive, are also the ones that leave the most debt.
Common Mistakes and Tips
- Optimizing execution when the problem is loading. During the 4.1 s of the baseline, your code had not run yet. Measure where the time is before deciding what to touch.
- Putting a classic
<script>with nodeferin the<head>. It stops HTML parsing. Withtype="module"the problem does not exist. - Using
asyncbelieving it is "deferbut better".asyncdoes not guarantee order and runs as soon as it arrives, possibly at the worst moment. - Reading Coverage right after loading and calling "dead code" what is only "code not run yet". They are two different questions and require two ways of stopping the recording.
- Believing bundling is "putting everything in one file". Today the goal is a few well-chosen files, separated by rate of change and by moment of use.
- Transpiling to ES5 "just in case". It inflates the bundle by 20–30% and adds polyfills no browser with ES modules needs.
- Disabling source maps in production. They are not downloaded unless DevTools is open, and without them a real error is unreadable.
- Assuming tree shaking works. It fails with
import *used opaquely, with modules that have side effects and with dependencies that only publish CommonJS. Check it with the visualizer. - Over-splitting. Every
import()is a round trip. Splitting a 3 kB module trades 3 kB for 280 ms onSlow 4G. - Using a bare
import(variable). The bundler does not know what to include. Leave the fixed part visible:import(\./texts/${code}.js`)`. - Not handling a dynamic
import()failure. A new deployment can delete the chunk an open tab is about to request. Detect the error and offer a reload. - Caching the promise of a failed
import(). Every subsequent attempt will fail forever. Clear it when the retries run out. - Preloading too many things. If everything is a priority, nothing is.
preloadworks because it displaces other resources in the queue. preloadof a font withoutcrossorigin. It causes two downloads of the most expensive resource on the page. It is the classic mistake.loading="lazy"on the LCP image. It deliberately delays the main metric. Lighthouse detects it and tells you off.- Images without
widthandheight. It is the number one cause of CLS, and the two numbers do not interfere with responsive design. - Serving a full 94 kB font to write in English. Make a subset: 21.4 kB with the same glyphs you use.
font-display: blockorauto. Invisible text for up to three seconds.swapat minimum,optionalif CLS rules.- Believing a skeleton improves LCP. It improves CLS and perception; LCP measures content, and a skeleton is not content.
- Not checking that compression is on. If "transferred" and "resources" match in Network, there is no Gzip and no Brotli. It is worth 150 kB to check.
- Recompressing WebP, WOFF2 or MP4. They are already compressed: you waste CPU and sometimes make them bigger.
- Caching the HTML or
sw.js. The HTML points at the hashed names; if it is cached, users stay on the old version forever. - Leaving the service worker's precache list hand-written after adding a build step.
addAllis atomic: one non-existent path and nothing is installed. - Precaching the lazy chunks. You would download at startup exactly what you had just taken off the critical path.
- Tip: always measure with Disable cache and
Slow 4G. Without that you are measuring your second visit on fiber. - Tip: separate chunks by rate of change, not just by size. That is what makes the cache work at a fine grain.
- Tip: save the visualizer report as a CI artifact. When the budget fails in three months, you will have the answer in thirty seconds.
- Tip: set binary assertions as
errorand time-based ones aswarn. "Compression is missing" is deterministic; "LCP 2.6 s" may just be the runner having a bad day. - Tip: write in the code the number that justified each optimization, with its date. Two years from now, whoever reads
VirtualListdeserves to know it was added to go from 7,812 nodes to 1,194.
Exercises
Exercise 1 — Diagnosis from the Network panel. A colleague deploys Nómada Tasks on her own server and shows you this summary bar and this extract, measured with Slow 4G in incognito:
| Resource | Size (transferred / resource) | Priority | Time |
|---|---|---|---|
index.html |
9.1 kB / 9.1 kB | Highest | 620 ms |
assets/index-8f3a1c.js |
44.1 kB / 44.1 kB | High | 1,240 ms |
assets/styles-4c9e21.css |
17.9 kB / 17.9 kB | Highest | 980 ms |
assets/reports-5a3f18.js |
18.2 kB / 18.2 kB | High | 1,310 ms |
assets/editor-7e1b93.js |
24.4 kB / 24.4 kB | High | 1,380 ms |
fonts/inter-variable.woff2 |
94 kB / 94 kB | Highest | 2,100 ms |
img/cover.png |
78 kB / 78 kB | Low | 2,900 ms |
Identify four distinct problems, say exactly what you base each claim on, and propose a concrete fix for each. Then estimate which of the four will have the biggest impact on LCP and why.
Exercise 2 — Deciding what to split. Taller Nómada wants four new features. For each one, choose between "static import", "import() on activation" and "import() with preloading in requestIdleCallback", justifying it with the three conditions from section 12 and with the cost of a round trip on Slow 4G (~280 ms).
- A 2.1 kB form validator used whenever any task is created.
- A 186 kB PDF exporter used by 3% of visits, always at the end of the session.
- A 34 kB emoji picker for comments; 40% of visits use it, typically within the first thirty seconds.
- A 6 kB accessibility module that installs keyboard shortcuts and runs as soon as the page loads.
Exercise 3 — Closing a CLS of 0.34. Nómada Tasks's new statistics panel has a CLS of 0.34, broken down like this according to the Performance panel: 0.18 from a cookie notice banner being inserted at the top, 0.09 from a chart loaded with dynamic import() arriving, 0.05 from the font swap, and 0.02 from a logo <img>. For each one: (a) explain why the shift happens, (b) write the concrete fix with its code, and (c) say whether the resulting CLS would meet the ≤ 0.1 target. Then answer: why does a shift caused by the user (pressing "See details" and having a panel expand) not count towards CLS?
Solutions
Solution 1
Problem 1: there is no compression. You can see it directly in the Size column: transferred and resource match on every text file (44.1 / 44.1, 17.9 / 17.9, 9.1 / 9.1), and the summary says 291 kB transferred · 294 kB resources. With Brotli, those 71.1 kB of JS and CSS would travel as about 21.6 kB.
gzip on;
brotli on;
brotli_types text/html text/css application/javascript application/json image/svg+xml;
brotli_static on; # serve the .br files generated at build timeProblem 2: the lazy chunks are being downloaded. reports-5a3f18.js and editor-7e1b93.js appear in the initial load at High priority. That means the HTML references them, almost certainly with <link rel="modulepreload"> tags added by hand "to make them faster". They are cancelling out exactly the benefit of code splitting: 42.6 kB of critical path that 89% of visits do not use. The fix is to remove those two modulepreload tags from the HTML and, if you want to get the editor downloaded early, do it from requestIdleCallback as in section 14.
Problem 3: the font is not subsetted and arrives extremely late. 94 kB and 2,100 ms, at Highest priority, is the complete font with no subsetting (section 17.2). Besides, if it takes 2.1 s it is because it was discovered late, while parsing the CSS. A double fix: pyftsubset to bring it down to ~21 kB and <link rel="preload" as="font" crossorigin> in the <head>.
Problem 4: the cover image is the LCP element and has Low priority. It is the heaviest (78 kB), it is a PNG and it is the last to arrive, at 2,900 ms. Low indicates that it carries loading="lazy" or that the browser deprioritized it because it did not know it was important.
<img src="/assets/cover-800.webp"
srcset="/assets/cover-400.webp 400w, /assets/cover-800.webp 800w"
sizes="(max-width: 640px) 100vw, 800px"
width="800" height="450" alt="View of the workshop"
fetchpriority="high" decoding="async">Which weighs most on LCP. Problem 4, no question. The LCP element is the cover image, and it arrives at 2,900 ms; no other fix can bring LCP below that moment. Converting it to WebP (78 → 22 kB), serving it sized to the viewport and raising its priority moves it up to about 1,100 ms. Problems 1 and 2 are next in importance, because they free up critical-path bandwidth that the cover image was having to share with 42.6 kB of useless JavaScript. It is a good illustration that network optimizations interact: taking weight off one resource speeds up the others.
Solution 2
| # | Feature | Decision | Justification |
|---|---|---|---|
| 1 | Validator, 2.1 kB, always | Static import | It fails condition (1) —it is not heavy— and it fails (2) —it is used straight away. Splitting it would trade 2.1 kB (≈0.6 kB with Brotli) for 280 ms of waiting in the middle of a form, which is the worst possible moment |
| 2 | PDF exporter, 186 kB, 3% | import() on activation |
It satisfies all three conditions exemplarily: it is very heavy, it is not used on the first screen and it is triggered by a button. Besides, whoever exports is already waiting a moment for the generation, so the 280 ms are camouflaged. And since it is used at the end of the session, preloading it earlier would mean spending 186 kB on the 97% of visits that will never use it |
| 3 | Emoji picker, 34 kB, 40% | import() with preloading in requestIdleCallback |
It satisfies the three conditions for splitting, but the probability of use is high and the use is early: if you wait for the click, four out of ten users will see a pause. Preloading it while the browser is idle gives the best of both worlds: off the critical path, but already cached when needed |
| 4 | Accessibility, 6 kB, always on load | Static import | It fails condition (3): it is not triggered by an action, it is needed from the very first moment. Splitting it would leave the application without keyboard shortcuts for the first 280 ms, and that would be an accessibility regression in exchange for 6 kB |
The cross-cutting reading: the decision does not depend on size alone. Cases 2 and 3 weigh very differently and both are split; cases 1 and 4 also weigh differently and neither is split. What rules is when it is needed and with what probability.
Solution 3
(a) and (b), shift by shift:
The cookie banner (0.18). It is inserted into the normal flow, at the top, after the page has already been painted, and it pushes everything else down. It is the most damaging case possible: a large displacement, affecting the whole screen. Two fixes, and the second is the good one:
/* ✓ Better: take it out of the flow. If it is fixed, it cannot push anything */
.cookie-banner {
position: fixed;
bottom: 0; left: 0; right: 0;
z-index: 100;
}/* ✓ Alternative if it must go at the top in the flow: reserve its height from the start */
.banner-slot { min-height: 64px; } /* the space exists before the banner arrives */The chart loaded with import() (0.09). Its container is 0 px tall until the component mounts, and mounting makes it grow all at once. It is exactly the warning from section 16:
#chart-panel {
min-height: 280px; /* or aspect-ratio: 16 / 9 */
contain: layout paint; /* it also isolates the recalculation (09-04) */
}The font swap (0.05). When the web font arrives, its metrics differ from the fallback's and the text reflows. The fix is section 17.4's: a fallback @font-face with size-adjust, ascent-override, descent-override and line-gap-override, plus font-display: swap and a preload with crossorigin.
@font-face {
font-family: 'Inter fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body { font-family: 'Inter', 'Inter fallback', system-ui, sans-serif; }The logo (0.02). The space reservation is missing:
<img src="/assets/workshop-logo-a91c4e.webp" alt="Taller Nómada"
width="180" height="48" fetchpriority="high" decoding="async">(c) Does it meet the target? Yes, comfortably. The fixed banner eliminates 0.18, the chart's min-height eliminates 0.09, the adjusted fallback eliminates 0.05 and the logo's dimensions eliminate 0.02: resulting CLS ≈ 0.00–0.02, well below the 0.1 target. It is worth measuring rather than assuming: the DevTools Rendering panel with Layout Shift Regions visually highlights any shift that is left.
Why a user-caused shift does not count. The CLS specification ignores shifts that occur within a window of 500 ms after a user interaction (a keypress, a click, a tap). The reason is that CLS aims to measure surprise, not movement: if Marta presses "See details" and the panel expands, pushing the content down, that is exactly what she asked for and it does not disconcert her. What ruins the experience is content jumping while she is reading, making her press the wrong button.
Two practical consequences of that detail. First: do not try to "hide" your shifts behind a fake interaction, because the goal is the real experience, not the metric. Second, and more useful: if an expansion takes more than 500 ms to happen after the click —because there is an import() in the way— the shift will count, and there you do have to reserve the space. It is one more argument for the previous point's min-height.
Conclusion
09-01's contract is closed. Ten numbers measured, ten numbers measured again with the same procedure, and a table that is no longer a list of complaints but a record of decisions: LCP from 4.1 to 1.9 s, INP from 480 to 42 ms, CLS from 0.21 to 0.02, longest task from 1,180 to 41 ms, render from 310 to 31 ms, recalculations from 96 to 1.5 ms, report from 940 to 52 ms of blocking, memory from +37.7 MB to +0.4 MB, nodes from 7,812 to 1,194, and initial JavaScript from 214 kB in 28 requests to 58.3 kB in 3.
You understand the performance that is decided before a single line runs. You know the critical rendering path and why CSS blocks rendering —so as not to show an unstyled page that would then jump— and why a classic <script> blocks parsing, with the complete table of defer, async and type="module" that closes what 01-03 and 06-01 noted. You know how to measure the real weight with the Network panel —Disable cache, Slow 4G, the Size column with its two values that give away missing compression, Priority and Initiator— and with the Coverage tab, which gave you the uncomfortable figure everything else follows from: 62% of the downloaded JavaScript was not running before the first card.
You know what a bundler does and, more importantly, why it exists: not to "put everything in one file", but to resolve the bare specifiers the browser does not understand, to eliminate the discovery cascade that cost 1.1 s across four levels, and to give files hashed names that make eternal caching safe. You have a real, annotated vite.config.js, with target: 'es2022' instead of over-transpiling, source maps enabled, and manualChunks separated by rate of change rather than by size. You know what minification does (214 → 118 kB) and what tree shaking does (118 → 104 kB), and why the latter is only possible because ES module imports/exports are static —05-04's restriction that now makes sense— with its four conditions for actually working: named imports, declared sideEffects, ESM dependencies and genuinely unreachable code.
You know how to split with dynamic import() applying the three conditions —that it is heavy, that it is not used on the first screen and that a concrete action triggers it—, with the report and its worker, the rich editor and the translations off the critical path: 46 kB less for 100% of visits. And you know how to do it properly: the cached promise that is cleared on failure, the partial literal that lets the bundler discover variable paths, aria-busy on the indicator, a dignified degradation when the chunk does not arrive, and detection of the new deployment that invalidated the hashes. You know the five resource hints and when to use each, with their three rules: preload little, never something you will not use on this screen, and crossorigin mandatory on fonts.
You know how to make images lazy —loading="lazy" except on the LCP one, decoding="async", fetchpriority="high" only for the LCP element, width and height always, WebP before arguing about lazy loading— and components, with IntersectionObserver and a generous rootMargin over already reserved space. And you know what almost nobody looks at: that a font can weigh more than all your JavaScript, and that font-display: swap, a subset from 94 to 21.4 kB, preloading with crossorigin and a fallback font with adjusted metrics are worth more than many hours of optimizing code. With those, and with image dimensions and a skeleton that reserves the cards' space, you closed the CLS from 0.21 to 0.02, knowing besides that a skeleton does not count for LCP and that a user-caused shift does not count for CLS.
You close the module with the infrastructure: Gzip and Brotli generated at build time rather than on the fly, without recompressing what is already compressed and always checking in Network that they are on; HTTP caching with a one-year immutable for hashed assets, no-cache for the HTML and for sw.js, and ETag for the rest; the reconciliation with the service worker from 07-05, whose hand-written precache list stopped working as soon as there was a build step and is now generated during the build, without including the lazy chunks; and 09-01's performance budget finally turned into something that breaks continuous integration: a script for bytes and requests —stable, deterministic and cheap— plus Lighthouse CI with its rule-based assertions, and rollup-plugin-visualizer's treemap saved as an artifact for the day somebody adds a 90 kB library without noticing. With the final honesty up front: row 7 stayed at 52 ms rather than zero, the 0.02 of CLS is a decision and not an oversight, all of this is lab and not field, and every improvement has had its bill in complexity —a build step, ninety lines of virtualization, a broken Ctrl+F, a destroy() in every class— that with six tasks on the board would not have been justified.
And this is where the module delivers something that is in no table. For Nómada Tasks to respond the way it does, you have had to build by hand, piece by piece, a very specific set of mechanisms: a declarative render that describes the screen from the state (06-06), a reconciliation by stable key that reuses nodes instead of destroying them, a centralized state with explicit invalidation and a version cache (09-02), a systematic cleanup with destroy() and AbortController so nothing is left dangling (09-03), a virtualized list that keeps the node count constant (09-04), and a code split with lazy loading, preloading and failure handling (09-05). That list is no coincidence: it is, almost point for point, what a modern framework gives you out of the box on day one. React asks you for a key in lists —which is literally your data-id—, Vue rebuilds only what changed, Angular ships code splitting in the router, and all three manage for you the lifecycle and the cleanup you wrote by hand. The difference is that now you know what problem they solve, what it costs to solve it and what you pay for not solving it yourself, which is exactly the position from which you can judge whether they are worth it instead of adopting them out of habit. That perspective is where the next module begins: Why Frameworks Exist.
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
