Caching Computed Results with the Cache API
A service worker can turn an expensive computation into a persistent memo table that survives navigation, tab closes and browser restarts — as long as every computed value is given a URL and a body, because CacheStorage stores nothing else.
This is one of the two things a service worker is genuinely good at in the Service Workers for Computation toolkit, itself part of High-Performance Computation Patterns. The other question — whether the computation should be running in a service worker at all rather than a dedicated worker it delegates to — is settled in Dedicated vs Service Workers for CPU Tasks. This page assumes that decision is made and solves the storage half: what the key is, what the value is, when the write happens, and what invalidates it.
The shape of the solution is a two-sided serialisation. On the key side, the computation’s parameters are flattened into a synthetic Request URL that no server will ever see. On the value side, the computed result is flattened into a Response body. Everything else — versioning, eviction, deduplication — is your code, because the Cache API deliberately provides none of it.
Minimal Reproducible Example
One file. It intercepts a route, returns a cached summary if there is one, and otherwise computes, serves and stores it.
// sw.ts — cache-first computation, keyed by a synthetic Request
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const CACHE_PREFIX = 'computed-';
const CACHE_NAME = `${CACHE_PREFIX}v1`; // bump when the algorithm changes
const KEY_ROOT = '/__compute__/'; // reserved: never a real route
/** Serialise a plain result into a cacheable Response. */
function toResponse(data: unknown): Response {
return new Response(JSON.stringify(data), {
status: 200,
headers: {
'Content-Type': 'application/json',
// The cache stores no metadata, so age has to live in a header.
'X-Computed-At': new Date().toISOString(),
},
});
}
/** Stable synthetic key: identical inputs always produce the identical URL. */
function buildKey(op: string, params: Record<string, string>): Request {
const sorted = Object.entries(params).sort(([a], [b]) => (a < b ? -1 : 1));
return new Request(`${KEY_ROOT}${op}?${new URLSearchParams(sorted)}`);
}
/** Cache-first lookup. Returns the response plus the pending write, if any. */
async function cachedCompute(
key: Request,
compute: () => Promise<unknown>,
): Promise<{ response: Response; write: Promise<void> | null }> {
const cache = await caches.open(CACHE_NAME);
const hit = await cache.match(key);
if (hit) return { response: hit, write: null };
const response = toResponse(await compute());
// Clone first: a Response body is a one-shot stream and the page gets the original.
const write = cache.put(key, response.clone());
return { response, write };
}
self.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url);
// A cache key is not a route. Fail loudly instead of silently hitting the network.
if (url.pathname.startsWith(KEY_ROOT)) {
event.respondWith(new Response('not a route', { status: 404 }));
return;
}
if (url.pathname !== '/api/summarise') return;
const key = buildKey('summarise', {
dataset: url.searchParams.get('dataset') ?? '',
algo: 'v1',
});
event.respondWith(
(async () => {
const { response, write } = await cachedCompute(key, async () => {
const bytes = await event.request.clone().arrayBuffer();
return summarise(new Float64Array(bytes));
});
// Off the response path: the page is not made to wait for a disk write.
if (write) event.waitUntil(write);
return response;
})(),
);
});
function summarise(values: Float64Array): { n: number; mean: number; p95: number } {
const sorted = Float64Array.from(values).sort(); // typed arrays sort numerically
let sum = 0;
for (let i = 0; i < sorted.length; i++) sum += sorted[i];
return {
n: sorted.length,
mean: sum / sorted.length,
p95: sorted[Math.floor(sorted.length * 0.95)],
};
}
Line-by-Line Walkthrough
caches.open(CACHE_NAME) on every call. It creates the named cache on first use and opens the existing one afterwards, so there is no setup step and no need to cache the handle in a module-level variable — the call resolves in tens of microseconds once the cache exists. The name is an opaque string scoped to the origin, which is exactly why the version belongs inside it: cache identity is the only invalidation primitive the API gives you. Several named caches can coexist per origin (network assets in one, computed results in another) and they draw on the same origin quota, so splitting them buys clarity and independent eviction, not extra space.
cache.match(key) rather than caches.match(key). The instance method searches one named cache; the global caches.match() walks every cache in the origin in creation order and returns the first hit. The global form is slower and, worse, can return an entry written by an unrelated cache — a precached network asset whose URL happens to collide with a synthetic key. Always match against the cache you opened. A miss resolves to undefined, never a rejection, so if (hit) is the whole miss check.
The synthetic key must be stable and collision-free. URLSearchParams percent-encodes values, which is what stops a dataset id containing & or a space from silently merging two different computations into one entry. Sorting the entries first makes key construction order-independent, so a refactor that reorders the object literal does not orphan every previously cached result. And matching is exact on the full URL including the query string by default — cache.match(key, { ignoreSearch: true }) would collapse every parameter combination onto one entry, which is precisely wrong for parameter-keyed memoisation.
KEY_ROOT keeps keys out of the network. Reserving a path prefix that the fetch handler explicitly refuses means a stray fetch('/__compute__/summarise?…') — from a debugging session, a prefetch heuristic, or a cache.keys() listing pasted into the address bar — produces an immediate 404 instead of a request to your origin for a URL that does not exist there. The key is an identifier that happens to be URL-shaped, and treating it as anything else is how caches quietly start serving 404 bodies.
Setting the key and value side by side makes the mapping concrete: parameters in, one entry out.
toResponse attaches headers the cache itself will not give you. An entry carries no creation time, no hit count and no size — the only metadata that survives is what you put in the Response headers. X-Computed-At is what makes an age-based staleness policy possible later (Date.now() - Date.parse(hit.headers.get('X-Computed-At')!) > MAX_AGE), and it costs nothing to write now. Note that Content-Type is documentation rather than machinery here: response.json() parses the body regardless of the declared type.
response.clone() before cache.put(). A Response body is a ReadableStream and reading it disturbs it permanently. Returning the original to the page while the clone goes to disk is the only ordering that gives both consumers a full body — see the second gotcha below for what the two failure modes look like.
event.waitUntil(write) instead of await write. The disk write is not on the critical path: the page can have its answer while the entry is still being persisted. waitUntil tells the browser not to kill the service worker before that promise settles, which matters because a service worker can be terminated within seconds of going idle. The call is legal here even though it happens after an await, because a FetchEvent stays active until the promise passed to respondWith() settles — and that promise is still pending at this point. Move the waitUntil after the return and it throws InvalidStateError; drop it entirely and roughly one write in twenty is lost on a fast-terminating worker, producing a cache that mysteriously never warms up.
Versioning and Eviction
Two things grow without bound if you ignore them: old cache versions, and entries inside the current one. Neither is handled for you.
Changing the computation — a new algorithm, a bug fix in summarise, a different rounding rule — invalidates every stored result at once. That is what the version suffix in the cache name buys: bump computed-v1 to computed-v2 and the entire previous generation becomes unreachable in a single step. Unreachable is not deleted, though, so the activate handler has to sweep it up.
activate sweep makes it gone. Without it, every algorithm change leaves another full copy of the results sitting in the origin's budget.// sw.ts — evict previous generations as soon as the new worker takes over
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(
names
.filter((n) => n.startsWith(CACHE_PREFIX) && n !== CACHE_NAME)
.map((n) => caches.delete(n)),
);
await self.clients.claim();
})(),
);
});
/** Bound the current cache: cache.keys() returns keys in insertion order. */
async function prune(maxEntries: number): Promise<void> {
const cache = await caches.open(CACHE_NAME);
const keys = await cache.keys();
if (keys.length <= maxEntries) return;
const stale = keys.slice(0, keys.length - maxEntries);
await Promise.all(stale.map((k) => cache.delete(k)));
}
The prefix filter is what keeps the sweep from deleting the asset cache alongside the computation cache, and it is why the prefix is a constant rather than an inline string. Note that cache.keys() returns keys in insertion order, which makes prune a first-in-first-out policy, not a least-recently-used one: an entry that is read on every page load is still evicted once enough newer entries arrive. Real LRU needs a hit timestamp, and since the cache stores no metadata, that means rewriting the entry on each hit — usually more expensive than the occasional recompute it saves.
Quota behaves differently from localStorage. There is no per-cache size limit and no exception when you cross a threshold; entries simply consume the origin’s storage budget, and the browser may evict the whole origin’s storage under pressure. navigator.storage.estimate() reports usage and quota, and navigator.storage.persist() requests exemption from automatic eviction — but neither changes the rule that every read must tolerate a miss for an entry you wrote thirty seconds ago.
Gotchas and Edge Cases
1. The cache stores Responses, not objects
The most common first mistake, and the error message names the wrong thing:
// BROKEN — TypeError: Failed to execute 'put' on 'Cache':
// parameter 2 is not of type 'Response'.
await cache.put(key, myResultObject);
Wrap it, and match the wrapper to the shape of the data:
// Plain, JSON-serialisable results
await cache.put(key, new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' },
}));
// Binary results — no stringify step, no parse step, no precision loss
await cache.put(key, new Response(floatBuffer, {
headers: { 'Content-Type': 'application/octet-stream' },
}));
Reading back is the mirror image: await hit.json() or await hit.arrayBuffer(). The binary path is worth reaching for whenever the result is numeric — a million doubles cost about 8 MB as an ArrayBuffer and roughly 20 MB as JSON text that then has to be parsed. Constructing the Response from a buffer copies it rather than adopting it, so when the buffer came from a dedicated worker via Transferable Objects & Zero-Copy, the zero-copy handoff ends at the cache boundary.
2. Response bodies are single-use streams
Consuming a body — .json(), .text(), .arrayBuffer(), or handing it to respondWith() — drains it for good. Skipping the clone fails in one of two directions, and both are silent:
const response = toResponse(result);
await cache.put(key, response); // WRONG: body consumed by the cache
return response; // page receives an empty body
await cache.put(key, response.clone()); // RIGHT: cache gets a fresh stream
return response; // page gets the original
Clone order does not matter (either copy may be stored) but clone timing does: response.clone() throws TypeError: Response body is already used if anything has already read it. Clone immediately after construction, before any logging middleware peeks at the body.
Response body is drained by whoever reads it first. Cloning is not a defensive copy of the object — it is the only way to hand a second reader its own stream.3. cache.put() rejects more inputs than you expect — and accepts one it should not
put() throws a TypeError when the request method is not GET, when the request URL scheme is not http or https, when the response status is 206 Partial Content, or when the response carries a Vary: * header. The GET restriction is the one that bites: a natural instinct is to key a computation by a POST whose body holds the parameters, and that cannot be stored at all. Flatten parameters into the URL, as buildKey does, and keep the original POST for the network path only.
What put() does not reject is a failed response. A 500 from an upstream fetch, or a Response you built from a caught error, is stored as happily as a good result and served back on every subsequent request until the version changes. Guard the write explicitly:
if (response.ok) event.waitUntil(cache.put(key, response.clone()));
4. Concurrent requests compute the same key twice
Two clients — two tabs, or a page and a prefetch — can ask for the same uncached key milliseconds apart. Both miss, both compute, and the second put() overwrites the first. For a 400 ms computation that is 400 ms of duplicated CPU and a doubled peak memory footprint. The cache cannot deduplicate this because nothing is written until the first computation finishes; the fix is an in-flight map in the worker’s global scope:
const inFlight = new Map<string, Promise<Response>>();
function dedupe(key: Request, run: () => Promise<Response>): Promise<Response> {
const id = key.url;
const existing = inFlight.get(id);
if (existing) return existing.then((r) => r.clone()); // clone per caller
const p = run().finally(() => inFlight.delete(id));
inFlight.set(id, p);
return p;
}
Each caller gets its own clone, because one Response cannot serve two consumers. The map lives only as long as the service worker instance, which is correct: after a termination there is nothing in flight to deduplicate.
Performance Note
Measured on a 2023 MacBook Pro (M2, Chrome 124) with performance.now() bracketing each call inside a fetch handler, and on a Pixel 6a for the mobile column:
| Operation | Desktop | Mid-range Android |
|---|---|---|
caches.open() on an existing cache |
0.05–0.2 ms | 0.2–0.6 ms |
cache.match() hit, 200 KB JSON entry |
0.3–1 ms | 1–3 ms |
response.json() on that entry |
1–2 ms | 4–8 ms |
cache.put(), 200 KB entry |
1–5 ms | 3–10 ms |
| Recomputing the same summary | 180 ms | 520 ms |
The rule of thumb that falls out: cache when the computation costs more than about 20 ms and the same key is likely to be requested again. Below that the deserialisation cost eats the win — a 5 ms computation whose result takes 2 ms to read back and parse has saved 3 ms and bought you a versioning problem. The write cost never appears in user-visible latency because it runs under waitUntil after the response has been delivered, so only the read side of the table belongs in the decision.
Where this pays off hardest is the expensive-parse case covered by Data Parsing & Serialization: parse a large dataset once (180 ms desktop, 520 ms mobile), pay 3–10 ms to store the derived summary, and answer every later request in under 3 ms — across tabs, across navigations, and after the browser has been closed and reopened. An in-memory Map in the page beats the cache on a single page view and loses on every dimension that matters afterwards, because it dies with the document.