# Cold start: measured separately, without pretending there is code splitting Bunny and Deno Deploy receive one JavaScript file. A dynamic import in that file can defer module evaluation, but it cannot avoid downloading or parsing the file. Cold performance therefore has two different measured parts: 1. Load the production-shaped single-file bundle. 2. Serve the first real request, including database waits and lazy route work. The benchmarks keep those parts separate because the deterministic request benchmark uses a local SQLite database, while the production browser-platform libsql bundle cannot open that file. Their numbers must not be presented as one directly measured total. ```bash deno run -A scripts/bench/cold-start/bundle-load.ts deno run -A scripts/bench/cold-start/first-request.ts listings deno run -A scripts/bench/cold-start/first-request.ts booking-nav ``` `first-request.ts` measures two scenarios. The default `listings` scenario serves the catalogue page `/listings`. The `booking-nav` scenario serves the booking page of a listing that sits on a site page, so the request also builds the menu above it. Results below were measured on 14 July 2026 with Deno 2.5.6 in this development container. Wall times depend on the machine. Bundle size, query count, and the latency slope are the more durable comparisons. ## Single-file bundle load `bundle-load.ts` builds the app core with the production edge build pipeline: one minified ESM file, browser platform, no code splitting. It does not include the thin `edge.ts` Bunny wrapper. It then runs each variant ten times in a fresh Deno process with `--no-code-cache`. One discarded fresh-process run per variant primes filesystem pages first. The balanced forward/reverse rotation puts each variant in each run position twice and balances pair order. Attribution uses the median difference between paired runs, not the difference between independent medians. ```text variant size import first request hello 0.00MB 1.0ms 0.8ms full 5.51MB 156.2ms 2.6ms lazy-entry 5.51MB 105.5ms 52.5ms no-wasm 4.02MB 148.1ms - no-big-strings 3.16MB 139.1ms - ``` The tiny hello file supplies the dynamic-import and path-resolution floor after the benchmark child has started. It is not a process-start measurement. `lazy-entry` puts the same app behind a dynamic import in the same file. It estimates where work is paid, not code splitting: about 106 ms remains in file loading and parsing, and the paired runs put about 51 ms of eager evaluation on the first request instead. The full and lazy files are the same 5.51 MB. The paired import differences put the app-core load above the tiny-file floor at 155 ms. Emptying the inlined WASM literals saves 8 ms, but that difference includes JavaScript parsing, base64 decoding, allocation, and copying. It is not a parse-only number. Emptying the other inlined large strings saves another 8 ms.
Raw bundle import samples and paired differences in milliseconds ```text hello: 1.4 0.8 1.0 0.9 1.0 0.9 1.1 1.0 1.1 1.1 full: 160.4 154.3 153.7 158.7 153.4 151.4 155.6 160.6 217.9 156.9 lazy-entry: 105.6 103.2 105.0 107.5 103.4 105.3 111.8 105.5 115.3 106.4 no-wasm: 148.9 146.9 144.2 143.2 147.6 150.0 148.5 144.6 178.3 151.5 no-big-strings: 138.5 135.2 144.8 139.3 137.6 138.9 134.9 140.8 172.0 151.4 full - hello: 159.0 153.5 152.7 157.8 152.4 150.5 154.5 159.6 216.8 155.8 full - lazy: 54.8 51.1 48.6 51.2 50.1 46.1 43.8 55.1 102.6 50.6 full - no-wasm: 11.5 7.3 9.4 15.5 5.8 1.5 7.1 16.1 39.6 5.5 wasm - strings: 10.5 11.8 -0.5 3.9 10.1 11.0 13.6 3.8 6.3 0.1 ```
The request in this benchmark is `/robots.txt`, which deliberately avoids the database. This isolates boot checks and any app work deferred by `lazy-entry`. It does not measure `/listings`. The default benchmark is the self-contained build. A production build that publishes shared browser assets to the optional CDN has a different file size. The `no-big-strings` variant is an attribution experiment, not an exact CDN artifact. ### Lean base request graph Measured on 23 July 2026 against clean main `b51380cb`, with Deno 2.5.6 and Sentry enabled. The comparison used production app-core bundles with the benchmark's large publishable payloads emptied, then ran 60 balanced fresh processes with `--no-code-cache`. The change keeps authentication, migrations, Markdown, and storage available but stops generic static requests from evaluating them before they are needed. | Variant | Bundle import | First `/robots.txt` | Combined | | --------------- | ------------: | ------------------: | -----------: | | Main | 235.9 ms | 13.0 ms | 250.9 ms | | Lean base graph | **181.9 ms** | **10.8 ms** | **193.3 ms** | The median paired combined improvement was **49.1 ms**. The middle half of paired runs improved by 27.5–80.2 ms. The generated bundle grew by 723 bytes, so this is evaluation deferral rather than a transfer-size win. The changes that produced it are: - import the shared session-key error without evaluating the authentication feature; - load admin API authentication only after its path matches; - load migration execution only for database-backed requests; - keep the generic page layout independent of storage; and - keep generic error pages independent of the broad public template and Markdown graph. ## Cold `/listings` `first-request.ts` prepares a fully migrated, setup-complete public site with 12 regular groups, two packages, and one active listing in each. It stamps one-off housekeeping before measurement. For each simulated latency it then starts eight fresh child processes. The balanced forward/reverse rotation puts each of the four latency levels in each run position twice and balances pair order. The levels stop at 20 ms, the expected worst case for a replicated database. Every child serves `/listings` twice. A valid sample must return status 200 and contain every seeded group and listing name, so a partial page, redirect, or error page cannot look fast. Every libsql execute or batch pays the configured fake network latency and is counted as one round trip. The baseline is `8d87b16f` (`origin/main` when measured). It was checked out in a detached worktree, then only this branch's benchmark harness was applied to it. The baseline and optimized code therefore ran the same fixture, validation, balanced order, zero-delay behavior, and eight-sample summary. Both runs were made in the same session in this container. ### Before and after Cold request: | Simulated latency | Before | After | Round trips before | Round trips after | | ----------------- | -----: | ---------: | -----------------: | ----------------: | | 0 ms | 86 ms | **48 ms** | 119 | **30** | | 5 ms | 167 ms | **114 ms** | 119 | **30** | | 10 ms | 238 ms | **176 ms** | 119 | **30** | | 20 ms | 378 ms | **297 ms** | 119 | **30** | Second request in the same process: | Simulated latency | Before | After | Round trips before | Round trips after | | ----------------- | -----: | ---------: | -----------------: | ----------------: | | 0 ms | 24 ms | **8 ms** | 114 | **25** | | 5 ms | 97 ms | **71 ms** | 114 | **25** | | 10 ms | 154 ms | **121 ms** | 114 | **25** | | 20 ms | 273 ms | **221 ms** | 114 | **25** | The median balanced-cycle four-point slope fell from about 14.9 to 12.1 sequential round trips cold, and from 12.4 to 10.5 warm. This proves the change removed latency-critical stages rather than only collapsing calls that were already parallel. At zero fake latency, the cold median fell by 38 ms (44%). At 20 ms, it fell by 81 ms (21%). Median absolute deviations were at most 9.1 ms for the baseline cold samples and 2.3 ms for the optimized cold samples.
Raw cold / warm request samples in milliseconds ```text baseline 0ms: 78.2/21.5 96.6/27.3 89.3/24.5 88.7/26.0 82.0/22.9 83.1/19.7 94.1/26.1 78.2/20.6 baseline 5ms: 203.5/97.5 177.3/98.1 185.1/94.7 166.0/98.9 159.8/92.2 158.3/95.7 162.3/88.9 167.5/102.8 baseline 10ms: 237.3/154.0 232.7/153.5 240.2/163.2 239.6/159.3 232.2/149.8 242.3/151.8 221.5/150.0 242.6/154.2 baseline 20ms: 379.8/272.9 367.9/274.3 404.4/278.3 377.0/274.0 372.3/272.4 460.0/270.4 386.1/273.8 356.5/272.3 after 0ms: 47.5/8.9 48.1/8.5 46.8/8.2 52.8/8.0 47.3/7.6 47.9/8.3 45.8/7.7 47.9/7.3 after 5ms: 114.9/69.1 113.7/69.1 116.0/72.0 113.8/71.1 113.3/68.2 115.8/70.4 114.0/71.0 245.1/103.4 after 10ms: 177.0/119.8 176.9/122.7 178.5/120.4 174.4/120.5 172.2/121.1 175.0/121.3 174.5/122.1 205.1/156.7 after 20ms: 295.3/221.9 301.2/220.1 296.0/221.3 297.5/221.2 298.9/222.2 294.3/221.0 300.1/219.3 293.6/221.6 ```
### What was slow The old group path repeated the full work for every regular group: - Project and decrypt that group's listings. - Read four parent/child relationship views. - Read membership and capacity maps. Each package separately repeated its member ids, prices, child relationships, memberships, and capacity reads. With 14 groups, the page made 119 round trips. ### What changed - One grouped projection loads all requested groups in one round trip and projects each shared listing only once. - All regular groups share one discovery classification. - All packages share one price read, one child-edge read, one membership map, and one capacity map. Each package still runs its own exact booking-tree limit over those shared facts. - Package checks run at the same time as regular-group classification work. - Group liveness no longer imports the 900-line payment-flow module just to use two loaders. In the one-file deploy this defers evaluation work; it does not reduce file parsing. ## Cold booking page with the site menu The `booking-nav` scenario runs the same method as the listings scenario, but the child serves the booking page `GET /ticket/benchmark-listing-1`. The preparation seeds the listings scenario's catalogue first, and then adds the site pages the menu reads. A root page named "Visit" carries a nested page named "Extras" and the benchmark listings 1 and 2. "Extras" carries one group. Extras sits off the chain of pages a booking for listing 1 marks in the menu. The scenario therefore measures a menu resolution that must skip an off-chain group. Every response must render its markers before it counts as a sample. A booking page's markers are its own booking form and the active chain in the menu. The chain is the Visit page and the listing itself, each as an active link. A response without the menu fails the run. Round trips count the same way as in the listings scenario: every libsql execute or batch pays the fake latency. The recorded runs measured three states on 20 October 2026 at the branch head under review in #2621. All three states ran the same harness and fixture. The baseline ran main's menu code from before #2621, and that code resolved every entry in the whole tree. The attribution state told the menu which entry the booking is for, and kept main's unscoped resolution. The merged state passed the same target and resolved only the entries on the marked chain of pages. | State | Cold 0 ms | Cold 5 ms | Cold 10 ms | Cold 20 ms | Cold round trips | Warm 20 ms | Warm round trips | | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | | Main's menu code (baseline) | 61 ms | 115 ms | 162 ms | 252 ms | 16 | 154 ms | 12 | | Menu target, main's unscoped resolution (attribution) | 67 ms | 154 ms | 225 ms | 368 ms | 23 | 241 ms | 17 | | Menu target, chain-scoped resolution (merged) | 63 ms | 136 ms | 197 ms | 317 ms | 20 | 197 ms | 15 | Warm requests at the other latencies (baseline / attribution / merged): 4 / 5 / 4 ms at 0 ms, 47 / 75 / 61 ms at 5 ms, and 83 / 130 / 106 ms at 10 ms. - The target alone added 7 cold round trips over main (16 to 23) and 116 ms at 20 ms. - The chain-scoped resolution recovered 3 of those 7 round trips (23 to 20) and 51 ms at 20 ms. Warm requests recovered 2 round trips (17 to 15) and 44 ms at 20 ms. - The booking page does not return to main's 16 cold round trips. Marking the page's place keeps the count at 20, 4 above a menu that marks nothing. - The listings scenario is unchanged. Main's menu code and the merged code both measured 23 cold round trips, 315 ms at 20 ms, and 14 warm round trips. - The largest median absolute deviation in any row was 3.1 ms. The 51 ms gap between the attribution and merged states at 20 ms sits far outside that noise. A re-run on 21 October 2026 at the merged code repeated the round-trip counts: 20 cold and 15 warm. Its cold medians were 71 / 146 / 207 / 325 ms at 0 / 5 / 10 / 20 ms. Its warm medians were 5 / 63 / 108 / 198 ms, with deviations at or below 2.7 ms cold and 0.5 ms warm. The cold medians sit a few ms above the recorded run at the same latency. That gap is run-to-run timing, not a query change. ## What the request benchmark excludes The request child statically imports `serve-app.ts` before its request clock. It therefore includes route-triggered module evaluation, database work, rendering, response encoding, and pending work, but excludes Deno startup and the eager app module graph. It uses source modules and local SQLite so the database can be wrapped deterministically. Use `bundle-load.ts` for the one-file parse/evaluation measurement and `first-request.ts` for request work and network-depth measurement. Do not add their medians and call the result an end-to-end production observation. ## Regression guardrails - `test/lib/server-public/listings-query-scaling.test.ts` proves total query count stays fixed as regular groups or packages are added. It also proves a listing shared by several groups is projected once before being mapped back to each group. - Existing public listings, package, ticket, and site-page tests lock the rendered behavior and dead-link gates. - `test/features/public/site-nav.test.ts` and `specs/pages/the-menu-above-a-booking.feature` lock the menu marking that the booking-nav scenario measures. - `test/lib/cold-boot-queries.test.ts` continues to lock the four-query general boot chain before route business work. - Production startup logs still report runtime/bundle load, request wait, boot setup, and Sentry separately.