The Tile Cache That Never Forgets
Ten minutes south of South Lake Tahoe, the road to the trailhead crosses into a dead zone and the phone drops from one signal bar to none. The hiker at the trailhead still has the map: contours, trails, the route line, all of it rendered on the device since breakfast. No spinner, no "you are offline" toast. The map came from a cache that has never been told anything expires.
WayMaker, the hiking route planner we build, renders OpenTopoMap and OpenFreeMap basemap tiles and stores them in a service worker cache with no expiry date and no eviction policy. A cached tile is forever-valid, so the cache keeps it forever. Read as a web architecture decision, that sentence looks like a dare. For a trail product it is the correct reading of what a tile is. The engineering worth talking about is in the costs we accepted alongside it and in the one valve that keeps the arrangement survivable.On a trail, offline is the default case
Most web applications treat offline as an edge case that gets a fallback screen, and that treatment is honest: their users are nearly always in range of a network. A hiker's day inverts the ratio. The route gets planned at home over Wi-Fi, perhaps as a plain-English request like "a shaded 10k loop near Tahoe with lake views", and it gets walked a valley away from the nearest router. Phone in a pocket, screen off, session running for hours with the network gone; that is the operating condition, not the exception.
Being installable is table stakes here. WayMaker ships as a PWA, so it lives on the home screen like the trail apps people already carry, and the service worker is what decides whether the second day of a trip still has a map. An offline mode marketed as a feature flag would amount to claiming the map's most important moment is optional. We took the opposite stance: offline is the primary path, and connectivity is the bonus.
A cached tile is forever-valid, and the fact deletes the expiry
Ask what changes in a basemap tile, and over what timescale. A tile is a render of open data (OpenStreetMap points and ways, OpenTopoMap's derived layers, OpenFreeMap's styles), covering a few hundred meters of terrain. That render changes at map-data cadence: trail realignments, logging cuts, seasonal closures land on the timescale of months. When one does land, the consequence for a hiker looking at a months-old tile is close to none. A tile has no clock inside it. It is not a stock price; its truth decays at the speed of the physical world, which for a patch of wilderness is glacial.
That argument is what deletes expiry. If correctness decays slowly, an expiry date buys nothing, and eviction costs a re-download bill plus a blank patch of map at exactly the wrong moment. So the cache keeps everything it has ever fetched.
The honest limit sits next to the argument rather than after it. A basemap does go stale in visible ways: the road that postdates the tile will not be on it, no matter how confident the route line crossing it looks. We ship the staleness. Given the alternative, a blank square in a valley at 18:00, we would ship the staleness again.
Two caches, because two kinds of data fail differently
The first instinct for any service worker is one cache, one policy. In WayMaker the two main kinds of content could not share one.
The app shell (documents, JavaScript, styles, fonts) is a versioned artifact. Serving an old shell from cache forever creates the classic version-skew failure: an old bundle calling endpoints whose shape has moved on, a UI half-updated against itself. So navigation requests run network-first with an offline fallback, which means a fresh shell whenever the network exists and the cached shell the moment it doesn't.
Tiles run cache-first, for the reason in the previous section. Two caches, two failures prevented: version skew on one side, a blank map in a valley on the other. The split was the pivot. The moment we stopped pretending one policy could govern both, the design stopped needing exceptions.
The price is storage, and the valve is CLEAR_MAP_DATA
The tradeoff is storage against bandwidth, and we chose storage without a cap. Every region a user has ever panned keeps its tiles, the cache grows monotonically with curiosity, and it cannot shrug anything off on its own. The bandwidth saved is real and recurring: no tile the user has already paid for once gets paid for again. Against that stands a risk we do not fully control: browsers cap storage quotas, and under pressure they can evict caches on their own judgment, wiping the offline guarantee without our knowledge or consent.
Our answer to both was to keep everything and hand the user the valve. A message channel into the service worker wipes map data and nothing else.
// the valve, reduced to its message
navigator.serviceWorker.controller.postMessage({ type: 'CLEAR_MAP_DATA' });
A user who has spent a year carrying three regions of tiles can reclaim the space in one tap and keep every saved route and account state. Re-downloading costs only the one region that matters for the next trip, the region currently under their feet. The valve is manual, and manual is deliberate. Automatic eviction is precisely the mechanism that produces blank maps, and we were not willing to let the cache make that decision while someone is standing on a trail.
Share links expire after seven days, on purpose
The deliberate forgetting lives on the server side. WayMaker's share links stop resolving after 7 days, at the server, by design. The choice is the mirror image of the tile cache, and it is what keeps the decision to forget nothing on the map side defensible. A shared route is a conversation between two people, not an archive: the recipient opens it, copies the GPX, and moves on inside a week. The expiry also tells the sharer something true about data custody. Your plans and your traces are not accumulating on our server by default. A stateful feature hosted forever becomes a liability, in storage that never stops growing and in the quiet obligation to keep maintaining it.
What we would do differently
We shipped no size counter. The cache cannot tell the user how much of their phone it holds. The first actor to say the word "storage" out loud is the browser's own eviction, which arrives unconsulted and takes the offline guarantee with it. If we rebuilt the settings screen this week, it would expose the counter next to the valve, refreshed on app open, with a plain line showing which regions carry the mass. Under all our choices, that one hurts on an ordinary Tuesday, not on a trail. Lesson drawn: a cache without a policy needs a dashboard more than it needs a policy, because the counter is what makes the tradeoff visible while it is being made.
The cache that never forgets is correct wherever forgetting has no value. Trail basemap tiles pass that test, share links fail it on purpose, and the distance between those two sentences is the entire design. Decide which kind of data you hold before deciding how long to hold it.
Thanks for reading this far. If this sounds like the problem you're wrestling with, tell us about it. A principal reads every message.
Start a conversation