Cache, Death & IndexedDB

Let’s start with real-world scenario of how a web app might be used, without naming anything specific (the reasons are obvious, but a bit of imagination is always welcome). Imagine a small abstract dashboard with metrics, used by a small analytics team to work with data collected from user interactions inside the app. These interactions are aggregated on the server into compact slices and delivered to the analyst’s client via regular fetch. The dashboard also has some basic caching logic, where these aggregated slices are passed through Cache API and dropped into plain localStorage on main thread. Data persists there, and the ~5-10MB (it depends) per-domain limit is more than enough. This allows the analyst to compare states and dynamics at different times of day in today’s peculiar conditions, where network connection is cut off at a fixed time in the evening, and by lunchtime the next day a report with daily key findings must be ready.

This kind of lightweight offline storage did its job, but had a bunch of obvious downsides — mostly because localStorage and Cache API were being used in ways they really weren’t meant for.

localStorage is synchronous and runs on the main JS thread. This means every read/write of these aggregated slices would block the UI. It wasn’t a disaster (maybe analysts had a different opinion, who knows), since the size stayed around 1–2 MB by the end of the day. But the hard 5-10 MB limit and the fact that persistence isn’t guaranteed (browser can just wipe the cache) made any scaling beyond that basically impossible.

Cache API has historically been chosen to solve the persistence problem (at least that’s what they thought). navigator.storage.persist() was used to disable best-effort storage behavior. JSON responses were stored raw, minimally chopped into chunks (for what?), ending up with a kind of predictable LRU cache (sigh), reducing randomness. So in different situations this allowed us to refill localStorage again or request missing slices if the issue happened while network connection was still alive. Boldly stretching Cache API over the globe.

Day X.

There was another similar product on the market that held a dominant position: its CCU was 3000–4000 users. With that gap it was hard to talk about direct competition, but thanks to some business advantages we managed to attract part of the audience, and a very small share of the market still went to us — which our miserable dashboard somehow pulled off, until that share suddenly grew to the scale of the whole market in just a few days. External conditions (regulatory pressure and more) forced the monopolist to shut down. The whole volume of users came to us, the product wasn’t ready for that, let alone the internal dashboard for analysts. The house of cards fell apart, and we had to quickly put together something better.

The main task was not just to keep the familiar analyst flow running on the new user load, but also to expand the dashboard’s functionality, involve backend devs as little as possible (they already had plenty on their plate), and solve the problems on our own.

New conditions

Storage/compute volume

From this it was clear we’d need a more reliable caching mechanism, and we had to seriously offload the main thread from rendering and derived metric computations. On top of that came support for weekly history, with aggregation/rotation of data.

Implementation, on fingers

Data & Cache

As the main offline storage for slices we picked Dexie.js (a wrapper around IndexedDB) to work with a more declarative API and ease the pain a bit. Cache API moved into a Service Worker and kept the role of a fallback for IndexedDB, with storage limited to the last 5 slices.

Web Worker Pool

Browser storage architecture: SW + Cache API with IndexedDB, worker pool and WebGL renderer
Data flow: SW/Cache → IndexedDB → WW Pool → WebGL renderer → UI thread

Stakeholders wanted it as fast as possible, and under heavy pressure we managed to spin up an MVP version of this system in just a month. We ran into plenty of problems — for example, memory spikes caused by postMessage (structuredClone under the hood), which we solved by switching to SharedArrayBuffer with COOP/COEP enabled. We hit GPU out-of-memory because some visualizations with too many points were kept “as is.” We spent a long time messing with a worker queue, and IndexedDB storage optimization ate more time than it should have. There was more, but this story keeps silent about it so the post doesn’t blow up in size even further.

Results & Takeaways

While we were stuck in downtime, rebuilding our service for a suddenly larger audience, that very audience slipped away: new players jumped into the market and grabbed their slice, while the overall pool of potential users shrank because we had nothing to offer for a while. In the end we had to settle for ~1200 CCU. We could have gone simpler and shipped way faster — for example, keep rendering on Canvas 2D in the main thread, drop Cache API entirely (the author of this post was pushing for that), and replace the whole worker pool with just one universal data worker. Instead, we tried to fix scaling at the wrong time, all at once — a serious mistake. But the bigger point this story makes is different: how important it is to design systems with scaling potential from the start, instead of living for years on hacks that can suddenly collapse when real growth hits.

“A man who does not plan long ahead will find trouble at his door.”

Confucius