Skip to content

Commit

React Router's handler is made once per isolate, and docs/PERFORMANCE.md says where the site's CPU went and how to measure it

Given the build as a function, createRequestHandler derives everything again on every request: every route wrapped for entry.server.tsx's timings, then the route table flattened and ranked. workers/app.ts now loads the build once per isolate and keeps the handler it made (never a promise of one, so no request waits on another's); development still passes the function, so changed files are picked up. docs/PERFORMANCE.md gains "CPU per page": October's Workers CPU (the site 83.3 million ms over 1.93 million requests, about 43 ms each), why Server-Timing cannot show CPU, how to measure it locally with the built site and fake services, where it went per page, the new caches in the caching table, and before and after per page. Measured locally, CPU a request as a crawler: a 130-line TypeScript file 27.3 ms to 7.8 ms once seen, an 1,800-line TSX file 255.6 to 41.3 ms, the Files page with a README 30.8 to 9.5 ms, pages with neither 10 to 30% less. Every page was rendered with both builds and is byte for byte the same apart from the build's version.

syntaqxcommitted Parent46afa90Browse files
3 files+116−50/3 viewed
+1−1
4444
4545 /**
4646 * About 8 MB of highlighted lines per isolate (two bytes a character); one
47− * file up to a quarter of that, which a 1,500-line file fits. A pull
47+ * file up to a quarter of that, which a 1,800-line file fits. A pull
4848 * request's first screens are at most 40,000 characters of text, about
4949 * ten times that highlighted.
5050 */
+18−4
1313 import { usercontentPath } from "../app/lib/usercontent";
1414 import { serveUsercontent } from "./usercontent";
1515
16−const requestHandler = createRequestHandler(
17− () => import("virtual:react-router/server-build"),
18− import.meta.env.MODE,
19−);
16+const loadBuild = () => import("virtual:react-router/server-build");
17+
18+/**
19+ * Given a function for the build, React Router derives everything from it
20+ * again on every request: each route wrapped for the timings in
21+ * entry.server.tsx, then the route table flattened and ranked, about a
22+ * millisecond and a half of CPU a page. The build never changes while an
23+ * isolate lives, so outside development it is loaded once and the handler
24+ * made once. Development keeps the function, so a changed file is picked up.
25+ * Only the made handler is kept, never a promise of one, so no request
26+ * waits on another's (a build that fails to load is loaded again next time).
27+ */
28+let handler: ReturnType<typeof createRequestHandler> | undefined;
29+async function requestHandler(request: Request): Promise<Response> {
30+ if (import.meta.env.DEV) return createRequestHandler(loadBuild, import.meta.env.MODE)(request);
31+ handler ??= createRequestHandler(await loadBuild(), import.meta.env.MODE);
32+ return handler(request);
33+}
2034
2135 const DOCS = "https://docs.g1t.sh";
2236
+97−0
195195 | A branch's log, the branch list, a file by branch and path | repos' data-centre cache | until the repository's refs change (`refs_version`), 5 min at most | only while no handed-out push credential is live; by commit hash for good (docs/ARTIFACTS.md R9) |
196196 | A target branch's history, for mergeability | the repos isolate | 60 s, per target head | 100 pull requests checked after a push walk it once (R10) |
197197 | Git store credentials | repos isolate and KV | reused 50 min (1 h tokens); 3 min for ones handed out | (R3) |
198+| A file's highlighted lines (blob, blame) | the site's isolate (about 8 MB), then the data centre's cache (`content.g1t.internal/highlight-lines/`) | for good (30 days in the data centre) | by SHA-256 of the language and the text, nothing else: the same text on any branch, commit or page is one entry. `HIGHLIGHT_VERSION` in `lib/highlight.server.ts` is in the key; bump it when the theme, grammars, Shiki or `linesToHtml` change. Nothing kept for a file without a language or over 200,000 characters |
199+| A pull request's first highlighted files | as above (`highlight-diff/`, about 4 MB per isolate) | for good | by the language and each line's side and text (`diffContent` in `lib/diff.ts`); line numbers and the path are not in it |
200+| Parsed markdown | the isolate, or the browser tab (400,000 characters of source, about 8 MB) | until pushed out | by the text and the repository its references point into (`components/markdown.tsx`); the tree is rendered with the page's components each time |
201+| React Router's route tables | the isolate | its life | the build is loaded once and the handler made once (`workers/app.ts`); development still reloads it per request |
198202
199203 ## Server-Timing
200204
358362 | Crawler, nothing moved | 460 to 570 ms | not yet measured on production |
359363 | Browser, first byte | 115 to 160 ms (`total`), 200 to 245 ms measured from Colorado | unchanged: the page does not wait for any of this |
360364
365+## CPU per page (2026-10-09)
366+
367+Workers bill CPU time past 30 million ms a cycle. From 1 to 9 October the
368+site (`g1t`) used 83.3 million CPU-ms over 1.93 million requests, about 43
369+ms a request and seven tenths of all g1t's Workers CPU; `g1t-repos` used
370+28.3 million over 6.58 million (about 4 ms). Signed-out pages are kept
371+for 30 s (above), but a crawler reads each file once, so most of its
372+requests render.
373+
374+### Measuring it
375+
376+`Server-Timing` cannot show CPU: a Worker's clock does not move while it
377+computes, so `total;dur=0` on a page that rendered for 20 ms is normal.
378+Measure locally instead, with the built site and fake services:
379+
380+1. `npm run build -w apps/web`.
381+2. Load `build/server/index.js` in Node with `cloudflare:workers` pointed
382+ at a stub whose `env` has a service binding per service, answering
383+ `POST /rpc/<method>` from fixtures. A page's `.data` (fetched signed
384+ out from production) decoded with React Router's turbo-stream decoder
385+ gives realistic answers; files and READMEs can come from the working
386+ tree.
387+3. Call the worker's `fetch` with a crawler's user agent (the whole page
388+ renders before the answer) and read `process.cpuUsage()` over 100
389+ requests after a few to warm up. Run Node with `--single-threaded` so
390+ garbage collection and compilation count on the one thread, as in a
391+ Worker; on Windows `cpuUsage` moves in 15.6 ms steps, so divide a long
392+ run, never time one request.
393+4. `node --cpu-prof` on the same loop says where it goes.
394+
395+### Where it went
396+
397+Profiled with fixtures from flagon-io/g1t:
398+
399+| Page | CPU a request | Where |
400+| --- | --- | --- |
401+| A 130-line TypeScript file | 30 ms | 63% highlighting (Shiki's tokenizer), 17% rendering |
402+| A 1,800-line TSX file (2.4 MB page) | 250 ms | 85% highlighting; the rest rendering and encoding the page |
403+| The Files page with a README | 32 ms | 46% parsing the README (remark, rehype-raw, sanitize, the plugins) |
404+| Any page | 1 to 1.5 ms more | React Router rebuilt its route tables for every request: given the build as a function, it wraps every route for the timings and flattens and ranks the route table again each time |
405+| `package-lock.json` (3.8 MB page, too large to highlight) | 170 ms | rendering one row per line and the file again in the page's data |
406+
407+The CSP nonce, `isbot`, Server-Timing bookkeeping and the signed-out
408+cache's own work were each under 1% of a page's CPU in the profiles.
409+
410+### What changed
411+
412+- **Highlighting is kept by content** (`lib/highlight.server.ts`,
413+ `lib/content-cache.ts`): the isolate first, then the data centre's
414+ cache, then Shiki. The key is a SHA-256 of the language and the text,
415+ with a version, so a file that is the same on another branch or commit,
416+ in blame, or for the next crawler is highlighted once per data centre.
417+ A pull request's first files are kept the same way.
418+- **Markdown is parsed once per text** (`lib/markdown-tree.ts`,
419+ `components/markdown.tsx`): the steps `react-markdown` runs on every
420+ render are split, and the parsed tree is kept per isolate (and per
421+ browser tab). `lib/markdown-tree.test.ts` renders README.md, this file
422+ and a set of edge cases (raw HTML, scripts, `javascript:` links, alerts,
423+ references) both ways and checks the HTML is identical.
424+- **The request handler is made once per isolate** (`workers/app.ts`).
425+- Shiki's module is asked for once per isolate, not on every highlight.
426+
427+### Measured
428+
429+Locally, CPU a request, median of three runs of 100 requests, signed out
430+as a crawler. "Seen" is a file or README this isolate (or data centre)
431+has highlighted or parsed before; "new" is one it has not.
432+
433+| Page | Before | After, seen | After, new |
434+| --- | --- | --- | --- |
435+| `/` (landing) | 12.7 ms | 10.6 ms | 10.6 ms |
436+| `/pricing` | 4.8 ms | 3.3 ms | 3.3 ms |
437+| Files, root with README.md | 30.8 ms | 9.5 ms | 23.7 ms |
438+| Files, `docs/` (a 30,000-character README) | 44.7 ms | 8.9 ms | 33.1 ms |
439+| Files, no README | 9.2 ms | 7.2 ms | 6.9 ms |
440+| A 130-line TypeScript file | 27.3 ms | 7.8 ms | 24.5 ms |
441+| A 1,800-line TSX file | 255.6 ms | 41.3 ms | 246.7 ms |
442+| A 560-line Rust file | 34.7 ms | 14.8 ms | 30.0 ms |
443+| A markdown file's source | 18.4 ms | 11.6 ms | 18.8 ms |
444+| `package-lock.json` | 200.2 ms | 134.1 ms | 127.8 ms |
445+
446+A pull request's first screens: a 288-line diff took 18.8 ms to
447+highlight and 0.15 ms to read back from the data centre's cache. Hashing
448+the text for the key is part of every "new" figure above. Small
449+differences (a few ms) are within the noise of these runs; the large
450+saving on `package-lock.json`, which nothing here caches, is partly that
451+noise and partly less garbage per request.
452+
453+What is left on large files is rendering: a row per line, and the same
454+lines again in the page's data for hydration. Files over 200,000
455+characters (lock files) are not highlighted and still cost about 130 ms
456+for a crawler.
457+
361458 ## Client navigation
362459
363460 - `<Link prefetch="intent">` on the sidebar, project tabs, breadcrumbs,