A1 · Part 3 · How it holds up
Performance, and how it is kept
Budgets that fail the check, code that loads only when it is asked for, and two places where the browser is told to skip work.
06On this page
Three ideas
Performance here rests on three things. The first is a set of budgets that fail the Lighthouse run when a page gets slower or heavier. The second is deferral: anything heavy loads only when a reader does something that needs it. The third is skipping work the reader cannot see, on the two pages that can grow without limit. This chapter covers all three, with the numbers as they stand today.
The budgets, and why they now run three times
The budgets live in .lighthouserc.json. npm run build and then npm run test:perf
start the production build on port 3101 and run Lighthouse, with the desktop preset, against three URLs: the home
page, one chapter (/notes/setup-mental-model) and /interview.
| Assertion | Limit | Level |
|---|---|---|
| Performance, accessibility, best practices, SEO | each at least 0.95 | error |
| First Contentful Paint | 1,200 ms | error |
| Largest Contentful Paint | 2,000 ms | error |
| Total Blocking Time | 200 ms | error |
| Cumulative Layout Shift | 0.06 | error |
| Script bytes | 700,000 | error |
| Font bytes | 250,000 | error |
| All bytes | 1,000,000 | error |
Four audits are switched off: long cache lifetimes, unused JavaScript, unused CSS and
csp-xss. The first three would report on choices made on purpose, like chunks that
load later. The last one would flag the policy's 'unsafe-inline', which is discussed
in the security chapter.
numberOfRuns is now 3. Locally, the home page scores 100 on
performance with 0 ms of blocking time. On a GitHub runner, back when CI ran it, a single run once measured 0.91 and
245 ms for the same page. That run was on the Dependabot pull request for Next, and it failed both
the performance score and the blocking-time budget. Nothing in the pull request had made the page
slower. The runner had been slow. With three runs, one bad sample can no longer fail the job.
There is a price. Lighthouse CI's default aggregation is optimistic: it asserts against the best of the runs. So a real regression now has to show up in all three samples before it fails. On the last CI run, before the workflow was removed, the step took 118 seconds, the longest step in the pipeline. It is no longer part of the merge gate: run it by hand when a change could make a page heavier.
What a page actually downloads
The table below comes from the most recent local production build. For each page it reads the prerendered HTML, collects every script the HTML references, and sums the files. That is the JavaScript a first visit pays for before anything is clicked. Here KB means 1,000 bytes.
| Page | Scripts | Raw | Gzip | HTML, gzip |
|---|---|---|---|---|
| Home | 20 | 906 KB | 285 KB | 31 KB |
| A chapter | 15 | 810 KB | 254 KB | 33 KB |
/problems | 14 | 810 KB | 254 KB | 33 KB |
/whiteboard | 14 | 764 KB | 240 KB | 7 KB |
| A problem page | 19 | 1,323 KB | 422 KB | 10 KB |
/practice | 19 | 1,323 KB | 422 KB | 7 KB |
The script budget measures bytes on the wire, so the home page uses about 41% of its 700,000. The two heaviest pages are the playground and the problem pages, which carry the code editor, and neither is on the Lighthouse URL list. Nothing guards how much they weigh. The browser smoke spec only checks that the playground works.
Loaded when asked
The build writes 81 script chunks totalling 11.6 MB raw. A chapter reader downloads 810 KB of that. Most of the difference is code that waits for a reason to load.
| Chunk | Raw size | Loads when |
|---|---|---|
| The TypeScript compiler | 3.47 MB | A TypeScript file runs, or the editor checks types |
| The whole exercise bank | 1.70 MB | A mock interview room opens. A problem page loads only its own exercise |
| ESLint for the browser | 1.28 MB | The editor lints for the first time, inside a worker |
| Sentry | 548 KB | Never, unless a DSN is configured |
Workers start lazily. There are four Web Workers, and each one is created by a
getWorker() the first time it is needed, then kept in a module variable:
jsWorkerruns JavaScript and TypeScript.pyodideWorkerruns Python.scriptWorkerruns C, C++, Ruby, PHP and Lua.toolsWorkerformats, lints and type-checks for the editor.
Opening the playground starts none of them. Switching a file to C, C++, Ruby, PHP or Lua sends
the script worker a warm message, so the compiler downloads while the reader is still
typing rather than after they press Run.
Runtimes come from a CDN. Pyodide 314.0.6, sql.js 1.14.2, wasmoon, Ruby's WASI
build, php-wasm and a Clang build all load from jsDelivr, at versions pinned in
lib/wasmAssets.ts. None of them is part of a deploy. What remains in
public/wasm/ is 1.4 MB: TypeScript's lib files, which the editor fetches one at a
time, and the 204 KB React sandbox.
Dynamic imports. There are five next/dynamic calls in four files:
- the whiteboard's
Board, withssr: false - the mock interview's room and scorecard
- the editor inside the room
- Radix's focus trap, once, in the shortcut help
Below that level, lib/codeLanguages.ts loads each of its 17 language grammars with
import() when a file in that language opens. The editor's Vim mode and minimap load
only when they are switched on.
Prefetch is opt-out. Next prefetches every visible link's route, chunks
included. There are 26 prefetch={false} links across 11 files, 4 of them in the
site drawer. Without them, a link from a light page to the playground makes every visitor pay
for the editor, and a page of chapter cards, such as a topic cover, the path page or the
system map, fires dozens of small requests as soon as it scrolls into view.
Skipping what the reader cannot see
The problems page. /problems lists all 538 problems in 130 groups,
one per chapter. Its HTML is 267 KB before compression. Every group has
content-visibility: auto and contain-intrinsic-size: auto 52px in
app/problems/problems.module.css. The browser skips layout and paint for groups that
are off screen and reserves 52 pixels for each, which is roughly a collapsed group header. The
auto keyword makes it remember a group's real height once it has been drawn, so the
page length stops jumping after the first pass. Since the latest change only the first group starts
open, so most groups really are 52 pixels tall.
The whiteboard. Board.tsx culls. When a board holds more than 150
elements, it renders only those whose bounds overlap the visible area, padded by 200 screen
pixels, plus anything selected or being edited. Below 150 elements it draws everything, because
the filter would cost more than it saves. The paper pattern is also dropped below each layout's
minimum zoom, where its lines would blur into grey anyway.
The search index has a budget of its own
Search fetches one JSON index per topic on the first keystroke. tests/search-index.test.ts
fails if any topic's index goes over 85 KB gzipped, or 140 KB for System Design, whose chapters carry sources and worked tables, or 300 KB for DSA, whose 48 chapters are the longest on the site. The limit was raised from 75 on 24 September,
after real content growth, not bloat, pushed two topics past it. Measured today, in bytes:
| Topic | Chapters | Index, gzip | Share of 87,040 |
|---|---|---|---|
| JavaScript | 41 | 77,347 | 89% |
| React | 57 | 76,150 | 87% |
| System Design | 24 | 73,293 | 84% |
| Interview | 27 | 64,246 | 74% |
| DSA (budget 307,200) | 48 | 221,261 | 72% of its own budget |
| This topic, before this rewrite | 18 | 11,500 | 13% |
The same test file checks that the compact index finds exactly the same words as the full chapter text: nothing gained and nothing lost. That is what makes the trim safe to keep.
Importing one constant imports the module. A sidebar once shipped the whole syllabus to every page because a client component imported one string from the file that held it. Tree-shaking drops unused exports, not unused properties of an exported object.
A budget has to cover the heavy pages. The budgets here are tight and they pass.
But they watch the three lightest kinds of page, and the heaviest page on the site is outside
them. Adding /practice to the URL list is the cheapest real improvement left.
Finished reading?
0 of 26 chapters read in this series