How this is builtGroundworkHow this is built
20 / 26 written chapters

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.

8 min read1 diagram6 sections
06On this page
  1. Three ideas
  2. The budgets, and why they now run three times
  3. What a page actually downloads
  4. Loaded when asked
  5. Skipping what the reader cannot see
  6. The search index has a budget of its own

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.

AssertionLimitLevel
Performance, accessibility, best practices, SEOeach at least 0.95error
First Contentful Paint1,200 mserror
Largest Contentful Paint2,000 mserror
Total Blocking Time200 mserror
Cumulative Layout Shift0.06error
Script bytes700,000error
Font bytes250,000error
All bytes1,000,000error

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.

PageScriptsRawGzipHTML, gzip
Home20906 KB285 KB31 KB
A chapter15810 KB254 KB33 KB
/problems14810 KB254 KB33 KB
/whiteboard14764 KB240 KB7 KB
A problem page191,323 KB422 KB10 KB
/practice191,323 KB422 KB7 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.

ChunkRaw sizeLoads when
The TypeScript compiler3.47 MBA TypeScript file runs, or the editor checks types
The whole exercise bank1.70 MBA mock interview room opens. A problem page loads only its own exercise
ESLint for the browser1.28 MBThe editor lints for the first time, inside a worker
Sentry548 KBNever, 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:

  • jsWorker runs JavaScript and TypeScript.
  • pyodideWorker runs Python.
  • scriptWorker runs C, C++, Ruby, PHP and Lua.
  • toolsWorker formats, 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, with ssr: 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.

Page scripts at load 240 to 422 KB gzip Grammar when a file opens 1 of 17 languages Web Worker on the first Run 4 kinds, kept once made Runtime from jsDelivr first run of Python, C... NEVER LOADED UNLESS ASKED TypeScript compiler 3.47 MB · exercise bank 1.70 MB · Sentry 548 KB
Only the green box is paid on arrival. Each later stage waits for something the reader does, and the bottom row waits for something most readers never do.

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:

TopicChaptersIndex, gzipShare of 87,040
JavaScript4177,34789%
React5776,15087%
System Design2473,29384%
Interview2764,24674%
DSA (budget 307,200)48221,26172% of its own budget
This topic, before this rewrite1811,50013%

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.

Two lessons that still hold

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.