Two dated measurements bracket the work on this page. On 17 August 2026 a headless Chromium with a software GPU, pointed at the live site at 1280 × 800, recorded thirteen long tasks totalling 56.9 seconds on the home page and never saw the room boot before its 25-second limit. The same script, the same evening, after the first round of fixes: seven long tasks, 3.6 seconds, booted in 3.6 seconds. Everything below is the ledger that came out of asking, for every page and every kind of device, what is actually downloaded and when. The chunk-splitting regex, the preload plugin, the adaptive resolution loop and the dither are on the three.js build page and are not repeated; this is the byte count.
The measurement
const browser = await chromium.launch({ args: ["--use-gl=swiftshader"] });
const pages = [{ name: "home", path: "/" }, { name: "about", path: "/about/" },
{ name: "shop", path: "/shop/" }, { name: "youtube", path: "/youtube/" }];
for (const pageSpec of pages) {
const page = await context.newPage(); // viewport 1280 x 800
await page.addInitScript(() => {
new PerformanceObserver((list) => {
for (const e of list.getEntries()) if (e.duration >= 50) { /* count + sum */ }
}).observe({ type: "longtask", buffered: true });
});
await page.goto(origin + pageSpec.path, { waitUntil: "domcontentloaded", timeout: 45000 });
await page.waitForFunction(() => {
const overlay = document.querySelector("#overlay");
return overlay.classList.contains("is-booted") || overlay.classList.contains("is-fallback");
}, { timeout: 25000 }); // bootMs, or null on timeout
const metrics = await page.evaluate(() => {
const nav = performance.getEntriesByType("navigation")[0];
const js = performance.getEntriesByType("resource")
.filter((r) => r.name.includes("/assets/") && r.name.endsWith(".js"));
return { dcl: nav.domContentLoadedEventEnd, load: nav.loadEventEnd, ttfb: nav.responseStart,
jsCount: js.length, jsBytes: js.reduce((n, r) => n + (r.transferSize || 0), 0),
long: window.__htclLong };
});
}
writeFileSync(`qa-output/load-${label}.json`, JSON.stringify({ ts, origin, rows }, null, 2));
| Page | Before → after |
|---|---|
| Home | long tasks 13 / 56,858 ms → 7 / 3,570 ms; boot: timed out → 3,562 ms; load event 2,800 → 2,166 ms; JS 4 files / 283 kB → 7 / 285 kB |
| About | long tasks 10 / 25,263 ms → 9 / 2,600 ms; boot 22,885 → 4,306 ms; load 10,330 → 5,568 ms |
| Shop | long tasks 5 / 18,215 ms → 5 / 545 ms; boot 25,327 → 909 ms; load 13,446 → 1,234 ms |
| YouTube (no room) | long tasks 3 / 551 ms → 1 / 88 ms; load 4,193 → 707 ms; JS 2 files / 7 kB both times |
Two cautions on those numbers. SwiftShader is a software renderer, so the absolute boot times are far worse than any real laptop and the shader-compile stalls are exaggerated; the ratio is what the script is for. And the JS byte count went up slightly on the home page between the runs (four files to seven) because the fix split one bundle into several, which is the trade the chunking makes: more requests, less parse before first paint. The long-task total, which is main-thread time the page cannot respond during, is the number that dropped by a factor of sixteen.
What a desktop downloads on the home page
Bytes on disk are from the repository's dist/; wire bytes are from curl against the live site with Accept-Encoding: gzip on 3 September 2026. Caddy's gzip is a lighter setting than a maximum-compression build tool would use, which is why the wire figures run a few percent above what gzip -9 gives.
| Request | Bytes on disk → on the wire; when |
|---|---|
/ (HTML) | 14,766 → 4,748. Carries the analytics tags, the phone-gate snippet, a preload for the splash portrait, and a low-priority preload for the cat model. |
| Two stylesheets | boot 25,882 → 6,659; courtHints 43,374 → 9,739. |
| Six shared modules | boot 22,438 → 7,877; main 15,034 → 5,459; courtHints 2,857 → 1,286; quality 1,762 → 1,001; escape 1,122 → 572; bag 746 → 422. 44 kB on disk, 16.6 kB on the wire. |
| Poster still | /site/court-poster.webp 109,120 → 109,582 (already compressed). fetchpriority="high"; it is what the visitor is looking at for the next second. |
| three.js runtime | three-DYkD2aq_.js 649,811 → 176,278. Preloaded by the injected snippet on desktops, then imported by the court module. |
| LTC tables | three-ltc-B0L8g37D.js 247,242 → 106,587. Same treatment. Fetched in parallel; only used when area lights are on (discrete GPUs). |
| The room | court 13,332 → 5,893; room 19,562 → 7,172; dither 5,804 → 2,287; journalStage 4,373 → 2,138 (the home page has a stage in its story section). |
| The cat | /models/helen-sit-v2.glb 2,163,088 → 1,988,513. Caddy gzips model/gltf-binary; the textures inside are already WebP so only the meshopt geometry blocks give anything up (8.1 %). Bound to the scene after the room is standing. |
| Four camera stills | /live-cam/api/cam/<cam>/latest.jpg, first requests staggered 220 ms apart starting 120 ms after the first render, then one camera every two seconds. Each is a JPEG frame from the camera proxy; the four measured 42,537, 46,033, 46,049 and 47,942 bytes on 3 September. |
| Two portraits | court-photo-left.webp 117,600 and court-photo-right.webp 69,926, for the frames on the side walls, after the reveal. |
Add it up and a first-time desktop visitor is at about 2.6 MB on the wire before the cameras start, and 1.99 MB of that is one file, the cat. The JavaScript that has to be parsed before the room can be assembled is 283 kB compressed, and the 106 kB of that which is lookup tables for area lights is parsed by the browser on every desktop even though only the discrete-GPU tier turns those lights on. Splitting the tables into their own chunk keeps them off the other 22 pages; it does not keep them off a laptop with integrated graphics on the home page. That is the next cut, and it is a one-line change to the plugin's page list plus a runtime check.
What a phone downloads on the same page
| Request | Bytes |
|---|---|
| HTML + two stylesheets | 4,748 + 6,659 + 9,739 on the wire, the same files. |
| Six shared modules | 16.6 kB on the wire, the same files. main.ts runs, sees the phone gate, and never requests the court module. |
| three.js, LTC tables, the room chunks | Not requested. The gate snippet in the HTML skips the modulepreload links and courtMod is null. |
| The cat | Requested. The <link rel="preload" as="fetch" fetchpriority="low"> for the 1.99 MB model is static HTML on the home and about pages, with no device condition, so a phone fetches a mesh it never renders. Low priority, after everything else, but fetched. The fix is to move that preload into the same gated snippet as the module preloads. |
| The camera frame | One <img> at fetchpriority="high" for the water camera; it is the largest contentful paint on a phone and is deliberately kept eager while every other image on the site is lazy. |
| Four stills | Same endpoints, refreshed by the HTML shell. |
So a phone's first view is roughly 40 kB of page and code plus one camera frame, against a desktop's 2.6 MB, and the one thing wrong with it is the unconditional model preload. It was read straight from dist/index.html and is the kind of thing a byte ledger exists to find.
The shop page
| Request | Bytes |
|---|---|
shop module | 5,480 → 2,252. The page script; Vite adds ordinary modulepreload links for the five shared chunks it imports. |
three.js + LTC + shopCourt + room + dither | 176,278 + 106,587 + 8,113 + 7,172 + 2,287 on the wire (the shop room is procedural geometry and uses the same material helpers as the court). |
| Catalog and decal manifest | /data/catalog.json 5,043 and /merch/decal/decals.json 458 on the wire. The page needs both before a hover can show a price. |
| Fourteen print-file decals | 440,641 on disk under /merch/decal/, PNG with alpha, loaded after the room is standing, faded in over 260 ms. Down from 2,675 kB of studio mockups, 1.55 MB of which was never drawn. |
| Three portraits | The frames on the walls that are not for sale reuse court-photo-left/right.webp, already cached from the home page. |
| No model | The shop never loads the cat. |
Cache lifetimes, per path
encode gzip zstd {
match {
header Content-Type text/*
header Content-Type application/javascript*
header Content-Type application/json*
header Content-Type model/gltf-binary*
header Content-Type application/wasm*
}
}
@hashed path /assets/*
header @hashed Cache-Control "public, max-age=31536000, immutable"
@cams path /cams/*
header @cams Cache-Control "public, max-age=60"
@day path /models/* /site/* /merch/* /vendor/* /icons/*
header @day Cache-Control "public, max-age=604800, stale-while-revalidate=86400"
@html path *.html /
header @html Cache-Control "no-cache"
@glb path *.glb
header @glb Content-Type model/gltf-binary
Hashed chunks are immutable for a year, so a returning visitor re-downloads JavaScript only when the build changes and the hash with it. Models, decals and site images are good for seven days and served stale for a day after that while the browser revalidates, and the model URLs carry a version query (?v=mv1) so a re-export can bust that cache without touching Caddy. HTML is no-cache: always revalidated, so a deploy is visible on the next load. The /cams/* stills are sixty seconds because they are the only files under those paths that are genuinely live. The whole block, and the rest of the server, is on the hosting page.
What the tiers cost on the GPU side
Bytes are half the budget. The other half is what the machine has to do with them, and the quality profile in src/site/quality.ts is a list of things that were measured to be too expensive somewhere. Shadow maps double the shader variants and were measured at about six seconds of compile on an Iris Xe, so they are on for discrete GPUs only. RectAreaLights bolt roughly two hundred lines onto every lit fragment shader and on ANGLE over Direct3D turned the first draw into a stall of twenty seconds or more; same gate. Starting a mid-tier laptop at a pixel ratio of 1.35 on a 1440-pixel window cost about a second per frame during boot, so the mid tier starts at 1.0 and lets the scaler climb. The full tier table is on the three.js build page; the point here is that every row in it has a number behind it, and the numbers came from a person watching a laptop struggle, not from a guess about hardware classes.
Where the assistants were
Reading your own ledger
The script above is forty lines and needs nothing but Playwright. Run it against production before and after every change that touches loading, keep both JSON files, and compare the long-task total, not the load event: a page can fire load quickly and then freeze for ten seconds compiling shaders. Then open the built HTML and read every <link rel="preload"> with the question "which devices is this for?", because a preload is a promise to download and the HTML makes it before any script can check who is asking.
How the model got to 2.16 MB from 61 is the shippable-mesh page. How the page decides which of these requests to make is the page architecture. The scene the bytes become: the three.js build. Plain version: the 3D website.