← Lab notebook

Fast for visitors on phones

Our browser driving sim, Seat Time, is built at AI Maker Lab with AI coding agents. During the week of 19 October 2026, opening its garage meant downloading 2.10 MB of gzipped JavaScript. The browser then had 7.9 MB of JavaScript to parse. We had engines, rain, kart tracks and a wheel talking to the browser. First, though, the phone had to get through the front door.

We wanted that door to open sooner. When we had the agents inspect source maps, translation text dominated the game chunk. The map attributed 7.4 MB of raw source to messages, against 1.4 MB for the whole game interface. Those are source-map quantities, not compressed download sizes. They pointed us toward a less obvious place to start than the cars.

First we had to see the load

We added stage events in Google Analytics 4, from page view through first input. That gave the load a sequence we could observe. It did not give us a before-and-after account of whether people stayed longer. We still don't have that comparison in the retained record.

For the build itself, we measured the game module and everything its static imports pulled in. We called that set the static closure. Looking only at the entry file would miss its dependencies. Looking only at compressed bytes would miss another cost: the browser must parse and run the code after downloading it. web.dev's code-splitting explanation describes that distinction.

Seven thousand messages came along for the ride

Three references kept roughly 7,700 messages alive in the bundle. Two passed around the whole message collection; another selected a message through a computed key. Our source-map diagnosis was that these references stopped the build from discarding unused messages. A small-looking convenience in the source had become a large download.

Explicit message references reduced the retained count to 2,508. That change alone brought the compressed closure from 2,102 to 1,565 KB. We then moved 29 conditional panels to load when opened. With both changes, it reached 1,370 KB: about a third less gzip than the starting point.

The panels still existed. We changed when their code arrived, rather than removing their features. Vite documents dynamic imports, the mechanism behind this kind of deferred loading. We also added a check against passing the whole message collection again. The fix needed to survive the next convenient helper function.

Recorded compressed JavaScript totals fall from 2,102 to 1,565 to 1,370 KB, then about 1,223 KB after a later manifest change.
Recorded build totals, rounded to KB gzip. The later manifest comparison has its own 1,374.0 KB baseline; this is a sequence of snapshots, not an additive waterfall.

Half the car data, every position intact

The car files offered another cut, but our first approach crossed a line. Quantization reduces the precision used to represent values. In our attempt, 15 contract checks failed, covering wheel radii, mirror planes and dial bands. A smaller model wasn't worth quietly changing the geometry those checks protected.

We dropped that position-quantization attempt. The retained meshoptimizer-based result kept vertex positions bit-exact, while normals—the directions used for surface shading—were snapped to a 2⁻¹⁰ grid. Across 26 car files, size went from 21,419,496 to 10,989,652 bytes. That's roughly half the original file weight, not half the car's vertices.

The qualification matters: positions were exact; not every attribute was. meshoptimizer's documentation treats quantization as a separate precision choice. Our record also showed production serving these binary model files without gzip or Brotli. The file-size reduction therefore addressed bytes that really travelled, rather than relying on transport compression we assumed was there.

Sound arrived according to its job

Audio had become another early passenger. The old loader's first-drive fetch set totalled 13.96 MB: 12.16 MB of effects and 1.80 MB of engine audio. We changed loading by role, so the drive didn't request everything together. We also derived compressed Opus files from approved WAV sources.

The resulting first-drive browser measurement was 0.97 MB. The comparison combines a computed old fetch set with a measured new one; it isn't a matched pair of browser captures. Across 155 converted files, the audio collection went from 25.3 to 3.1 MB. Eight seamless loops stayed as WAV.

First-drive audio was 13.96 MB from the old loader's computed fetch set and 0.97 MB in the new browser measurement.
Less audio was fetched on Drive. The old total is calculated from the fetch set; the new total was measured in the browser.

We kept an explicit approval link between each derived file and its source. An Opus file without a matching derivation record could not play. That was our design rule, not a listening result. The Opus standard defines the codec; it does not establish that our encodes sound identical. No new listening verdict for this delivery change is recorded here.

The game barely read the manifest

The environment manifest was carrying more information than runtime needed. We replaced it with a runtime projection: a smaller document containing the fields the game actually used. Its raw size fell from 3,337 to 962 KB. In that comparison, the compressed closure fell from 1,374.0 to 1,223.4 KB, saving 150.6 KB.

That baseline differs slightly from the earlier 1,370 KB snapshot. We have kept the two comparisons separate rather than making the steps add up artificially. The broader pattern was becoming clear to us: first-load weight included data that was useful somewhere, but unnecessary at this particular moment. Translation helpers, unopened panels and authoring metadata all reached the garage together.

Starting at the click bought less than we hoped

We next started downloading game chunks at the click, alongside the server-data request. The chunks began around 370 ms, before the data response at around 480 ms. That removed some waiting between tasks without changing what ultimately had to arrive.

The improvement was modest under emulated Slow 4G and fourfold processor slowdown. Median full load went from 15.85 to 15.23 seconds. Client navigation went from 13.75 to 13.47 seconds. Rounded, those gains were 0.6 and 0.3 seconds, respectively. Chrome's throttling documentation describes emulation; these were not measurements from visitors' phones.

We kept the change, but it didn't make the remaining download disappear. About 1.9 MB still had to arrive before the garage was usable. Starting sooner helped. Sending less remained the larger unfinished job.

The import problem returned on the server

The server had its own version of unnecessary work arriving too early. Across 22–25 October, three incidents shared that shape. They had very different consequences: a refused deploy, a blocked publish, and one user-facing failure. Calling all three outages would erase the safeguards that actually caught two of them.

On 22 October, Cloudflare rejected a deployment with startup error 10021. Production stayed on the previous version. Cosmetic validation had reached through a renderer manifest into car physics, just to obtain a list of car identifiers. Importing that chain constructed the car specifications during initialization. Cloudflare's startup documentation connects that error to excessive global-scope startup work.

On 24 October, a startup check caught the course catalog entering the server again. This time a lap-validation check wanted track metadata. Replacing that dependency with a plain circuit-length table removed the route into the catalog. Publishing was blocked before that version reached players.

On 25 October, the failure did reach players. Fresh server instances spent 4–6 seconds of processor time on signed-in competition reads, with 7–17 seconds of elapsed time. Warm requests took about 20 ms. The Challenges screen could not load round availability. Local profiling pointed to all ten circuits being constructed during module loading; the scheduled competition job also exceeded memory.

October 22: deployment refused. October 24: publishing blocked. October 25: Challenges failed for users. Three import incidents, one user-facing failure.
The same dependency mistake reached three different boundaries. Only the final incident was a recorded user-facing failure.

A timeout couldn't interrupt the work

Our deadlines were in the wrong position to rescue this failure. The module work ran synchronously, occupying the event loop that would also run the timer callbacks. Neither the 750 ms opening budget nor the eight-second read deadline could interrupt it. Waiting for a timeout was still waiting for the circuit construction to finish.

We separated lightweight identifiers from geometry, built circuits only when needed, and stored precomputed fingerprints instead of rebuilding tracks to derive them. Checks guarded the import boundary and fingerprint drift. The local competition-endpoint cold import fell from 16.4 to 0.5 seconds. Recorded geometries and 24 ladder fingerprints remained byte-identical. That was local profile evidence; we have no retained post-fix production processor measurement to turn it into a hosted-performance claim.

There were smaller doors to unblock, too

A “storage unavailable” error turned out to be full localStorage. We moved ghost laps to IndexedDB and a binary representation: about 180 kB per lap instead of about 1.3 MB of text. A ghost is the stored lap you can race against. MDN's storage guide explains the distinction between string-only Web Storage and larger browser-managed stores. IndexedDB still has quotas; the move didn't make storage unlimited.

On the server, a batched access-policy read replaced two interactive transactions. We also enabled Workers Logs after server errors that live tailing hadn't captured. These changes addressed different parts of the wait. We aren't combining them into one universal speedup or claiming that the week improved retention.

The garage still had weight to lose

By the end of this work, we had smaller downloads and a clearer explanation for the server failure. We also had limits: roughly 1.9 MB before a usable garage, no visitor-retention comparison, and no recorded production processor result after the final import fix. The smaller totals tell us what changed. They don't tell us how long anyone chose to stay.

Open the garage on a phone. Next, we wanted the wheel to tell the truth about grip.

Previously: a steering wheel in a browser tab · Next episode: grip is the rack going light

Notes and sources

Measured — browser build notes, 19–25 October 2026: 2.10 MB gzip and 7.9 MB parse input; source-map attribution of 7.4 MB messages versus 1.4 MB interface source; compressed closure snapshots and message counts. Source-map source totals and emitted-code totals describe different representations. The later manifest comparison uses its own baseline. These are retained first-party observations, not newly rerun measurements.

Measured — asset notes: 26 model files and the byte totals above; 15 failed geometry checks; bit-exact positions with rounded normals. Audio before is calculated from the old loader's fetch set; audio after is browser-measured. Chosen design value — derived audio must match an approved source record; eight loops stay WAV. No recorded listening verdict — this delivery change is not an audio-quality comparison.

Measured, emulated — the click-start timings use Slow 4G and fourfold processor slowdown, not field data. Measured, production — the cold and warm competition-request observations belong to 25 October. Local profile only — the 16.4-to-0.5-second cold import and geometry/fingerprint comparison do not establish hosted acceptance. Our interpretation — message retention and synchronous module work explain the two dependency problems. Sourced fact, first-party release notes — the earlier incidents stopped deployment and publishing respectively. No post-fix production processor result is retained.

Measured — ghost-lap representation sizes are approximate. Sourced fact, first-party implementation notes — load-stage instrumentation, the batched policy read and logging changes. Public references explain mechanisms, not our measured outcomes: JavaScript splitting, Vite imports, meshoptimizer, Opus, network emulation, Worker startup, and browser storage. Read on 6 November 2026. Vendor names identify tools, not endorsements. AI assisted this article's research synthesis, writing and localization.

Keep reading