The planner started with request-time work. A request supplied transit bundles, the planner built or prepared route structures, and the search ran before the page received a result. That was a reasonable shape while planning meant a small number of direct route checks. It became a poor boundary once the graph carried directed services, transfers, walking, and richer candidate selection.

The available history records the reason for the pivot as edge CPU pressure. It does not preserve a reproducible before-and-after benchmark with the device, sample count, warm-up conditions, or exact failure rate. Earlier records' precise 560 to 1,900 millisecond and 50 millisecond figures are not repeated here.

The earlier planner surface gathered possible trips, attached bus-arrival information, and ranked a final Trip object in the request path. A slice of that historical implementation from 3dfa11b shows the shape. The excerpt is partial:

example.ts
1 const arrivals = indexBusArrivals(input.busArrivals ?? [])
2 const trips = possibleTrips(input).map((trip) =>
3 addFirstBusArrival(trip, arrivals),
4 )
5 const bestTrip = rankTrips(trips, request.preference)[0]
6 if (!bestTrip) {
7 return {
8 kind: "noConnectingRoute",
9 origin: request.origin,
10 destination: request.destination,
11 }
12 }

This was not bad code for the earlier contract. It did expose two problems as the planner grew. Static route preparation and query-specific work were mixed together, and the result depended on a server-side path that had to carry the cost of doing that work for each request.

Commit 89cad3b records the larger replacement. It added the planner compiler, image reader, mount code, reverse search, overlay, and selection modules while removing the earlier per-mode planner files. Commit 8366918 then moved the preparation call behind a dedicated web worker. The architecture changed in two steps: first make the static graph a build artifact, then move the query off the main page thread.

The important change is not that the browser became a faster server. Static topology now has a publication boundary, and query work has a worker boundary. The same image can serve repeated searches until the source bundles produce a new image.

What the current path does

The build script compiles the graph twice, compares the bytes and digest, writes a content-addressed wayplan file and manifest, and publishes the pair. The web client fetches the manifest and image, checks the byte length and SHA-256, and transfers the ArrayBuffer to the worker. The following is a complete excerpt of that transfer call:

example.ts
1 worker.postMessage(
2 {
3 type: "INIT_IMAGE",
4 imageVersion,
5 imageBytes,
6 },
7 [imageBytes]
8 )

Source: trip-prepare-client.ts. The transfer list avoids cloning the initial image message. It does not mean that the worker can use every section as a raw view. mountPlannerImage decodes strings and copies numeric sections into runtime arrays.

The worker verifies the image again before it mounts it:

The following is a partial excerpt from worker initialization:

example.ts
1 const bytes = new Uint8Array(message.imageBytes)
2 await verifyPlannerImage(bytes, message.imageVersion)
3 mountedImage = mountPlannerImage(bytes, message.imageVersion)
4 ctx.postMessage({
5 type: "IMAGE_READY",
6 imageVersion: message.imageVersion,
7 sha256: message.imageVersion,
8 } satisfies TripWorkerResponse)

Source: trip-worker.ts. After IMAGE_READY, PLAN_TRIP calls planMountedTrip with the request overlay, reverse search, candidate selection, and result materialization. The current browser request supplies an empty liveBusArrivals list, so this path plans from static topology and mode-specific default waits.

The checked-out manifest reports an image size of 26,851,432 bytes. That is a generated snapshot, not a permanent size contract. Workbox's current precache limit is 32 MiB, and the web build includes wayplan files in its precache glob. The current planner image path does not use a planner-specific Dexie or IndexedDB store.

What improved and what became harder

The pivot removed repeated static graph construction from the request path and moved graph search away from the main UI thread. Once the image is cached, static route planning can continue without a network request. The content hash also makes it possible to identify which graph the worker mounted.

The cost is visible in the code. The project now maintains a binary format, a manifest contract, a double-verification path, a worker protocol, and a cache update path. Mounting allocates typed arrays and JavaScript strings. Query planning allocates overlay nodes, labels, candidates, and result objects. A worker fault leaves the planner unavailable until the image is loaded and mounted again.

The client also does not cancel an old search when a rider changes a pin. It rejects the older pending promise and ignores a late response by request ID. That keeps stale output off the page, but an old search can still consume worker time.

What the evidence does not show

The commits establish the architecture change and the current source establishes the image, worker, and cache contracts. They do not establish a universal latency number, a fixed memory budget for every phone, or a guarantee that all service-worker update races are handled. The current profile command measures mounted planning for named scenarios, but it is not a historical comparison harness.

The remaining weakness is the same one that caused the pivot: the artifact boundary solves repeated work, not data quality. If a source bundle contains a wrong direction, geometry, or stop identity, the worker will faithfully search the wrong graph. That is why the compiler validation and planner regression tests remain part of the release path.

For the format and publication details, read Planner Image Compilation Pipeline. For cache, readiness, and stale-result behavior, read Offline Cache and Worker Lifecycle. For the engine that runs after mounting, read Trip Planner Engine.