architecture and privacy · 8 min read
Private by architecture, not by promise.
Privacy policies are promises, and promises rot as codebases grow. astray takes the other path: build the system so the sensitive data has nowhere to go. That one decision shaped the data sources, the deployment, and the code layout more than any feature did.
Trust comes in rungs.
A route request is sensitive by nature. It says where you are and where you are going. So instead of asking for trust, astray offers a ladder, and you pick the rung. Use the public instance: a stateless server pinned to a release and configured to log nothing. Or run the engine entirely on your own device, compiled to WebAssembly, where the request never leaves your browser at all.
We are explicit about what each rung guarantees. On-device is the rung you can verify yourself; we do not oversell the public instance. The interesting engineering is that both rungs run the same code. That is not an accident, and the rest of this article explains what aiming at that ladder changed.
What privacy changed in the architecture.
Privacy is the constraint the rest of the architecture bends around. It decides which data sources the product may touch, where computation runs, and how much trust we are allowed to ask for. Several of the resulting choices look worse on paper than the cloud-router default. Every one of them was deliberate.
No live-traffic provider. A traffic query is a location beacon: every request tells the provider where you are and when. So edge times are free-flow, derived from speed limits and road class, which means our "fastest" runs optimistic exactly when highways jam and a scenic backroad is most tempting. The workaround is almost embarrassingly small: an optional field where you type the ETA your maps app is already showing you. The budget anchors to that number. You had it on screen anyway; we never see your account, your history, or your location. The jammed-101 trip that started the project worked exactly this way.
We built our own geocoder. Autocomplete sends every partial place name you type, and those are often the most sensitive strings in the whole session: home addresses, medical destinations. Routing privacy with leaky geocoding is half a guarantee. So the search index is our own baked artifact: a compact finite-state transducer over normalized names1 (a structure that shares common character sequences to stay small) plus a quadkey-bucketed spatial index, queried by a pure Rust crate. It replaced a third-party geocoder that wanted a 4 GB JVM heap and tens of gigabytes of index; the whole deployment dropped from roughly 6 GB of RAM to 1 GB, and because the index loads whole and does no I/O, the same code now runs entirely in your browser once you download a region.
The server rejects what it should never hold. Your device computes a private familiarity overlay from trips you chose to save. The public server refuses, in code, any request carrying that overlay, so routing against it only works on-device. The boundary holds because it is compiled in, not written down.
No silent cloud fallback, ever. Future voice and natural-language features sit behind provider registries where every provider declares whether it runs on-device. A cloud provider can never be auto-selected, silently substituted, or made the default. The registry itself enforces the rule, because "we would never do that" is exactly the kind of promise that rots as a codebase grows.
The same engine, everywhere, because it does nothing else.
The engine core is a pure Rust library that takes bytes in and returns routes out, with no I/O, no network, and no threads. That single rule is what everything else hangs off. The HTTP server is a thin shell around it, and the WebAssembly build is a recompile of the same crate. Privacy tiers are a config flag because the architecture made them one: there is no "server algorithm" and "app algorithm" to drift apart, and a fix to loop routing or the price search lands on every rung of the trust ladder at once.
The tiling scheme is what makes the on-device engine practical: tiles live on a fixed global grid addressed by quadkey, border nodes carry stable OpenStreetMap identity so independently-built regions stitch at their seams, and the browser only ever needs the corridor plus a one-hop halo. That is the difference between loading a state and loading a trip.
How those tiles get made: the pipeline from raw OpenStreetMap extracts to quantized, stitchable graph tiles. The bake →What the builds measure.
Everything below is measured from real builds of the reference region (the San Francisco Bay Area) and an earlier three-state corpus (California, Oregon, Nevada). None of it is estimated. Live coverage is now the contiguous United States plus Hawaii.
| metric | measured |
|---|---|
| SF Bay Area graph blob | 127 MiB · 725,774 nodes · 907,864 edges (~1.65M directed) |
| California blob | 792 MB · 5.2M edges · ~27 GB peak RAM to bake, ~12 min |
| Per-edge scenery cost | 14 signals × 1 byte, so the whole vector rides in 14 bytes per edge |
| Per-request scratch | ~19 MiB transient per in-flight route (16 B × nodes + 8 B × edges) |
| Serving footprint | a couple of concurrent users fit a 1 vCPU / 1 GiB box |
| Tiling | global fixed-origin quadtree, ~100k edges/tile budget, topological 1-hop halo |
| Tiled vs whole | SF → Sacramento loads 41 tiles (271 MB) instead of the 792 MB state blob |
| Cross-seam parity | Truckee → Reno routes identically from independent CA/NV builds vs a combined build |
| US + Canada projection | ~8.7 GB load-whole, the number that makes tiling mandatory |
Where the current design runs out.
A project this size ships by choosing its debts. These are ours, on the record:
- Free-flow times understate congestion. Without live traffic, our fastest-time baseline can be far below reality on jammed corridors. The reference-ETA field is a pragmatic patch that depends on the user bothering to type a number.
- Stable edge identity is best-effort across map refreshes. Routing edges are way-segments split at intersections, and splits drift when OpenStreetMap changes. IDs are stable within one extract and best-effort across extracts; a big enough upstream edit can orphan device-local familiarity data for the affected edges.
- Geocoder fuzziness is bounded. Typo tolerance caps at roughly two edits, with no token reordering and no phonetic matching; autocomplete recall is top-K by baked importance. Good enough to dogfood, visibly behind a mature geocoder on the long tail. A dedicated quality pass is queued, and because the same index now runs on-device and on the server, one pass will improve both tiers at once.
- Some corridors genuinely have no alternative. On coastal stretches where Highway 1 is already the scenic option, the graph carries no parallel road of comparable length, and the scenic route diverges only by small bypasses. The diagnostic test for the worst such corridor stays in the suite, ignored and annotated, so the case remains on the record.
- One-shot plans are capped at about a long day's drive. A coast-to-coast request would resolve a corridor of hundreds of tiles, which is both a latency cliff and an easy denial-of-service against small self-hosted instances. Coverage is the contiguous United States plus Hawaii; multi-day trips can arrive later as chained legs.
- The server holds an owned copy of the graph at load. The blob format supports zero-copy access, but the server currently deserializes to owned structures, costing roughly twice the blob size in transient RAM during startup. It is measured and queued behind more urgent work.
- Long routes take seconds. A cross-region scenic search uses 2 to 6 seconds of one CPU core. Common routing accelerators such as contraction hierarchies2 precompute shortcuts for finding one lowest-cost path. astray changes road costs with each preference set and generates intermediate candidates, so those shortcuts do not apply directly. The current implementation bounds the number of candidate searches instead.
References.
- A. Gallant. "Index 1,600,000,000 Keys with Automata and Rust." blog.burntsushi.net (2015). The finite-state-transducer indexing approach (and the
fstcrate) our geocoder's text index is built on. - R. Geisberger, P. Sanders, D. Schultes, D. Delling. "Contraction Hierarchies: Faster and Simpler Hierarchical Routing in Road Networks." Workshop on Experimental Algorithms (WEA) 2008. The dominant speedup technique for fastest-path engines, cited here as the thing we deliberately did not use.