@dmytromykhailiuk/cache-request
v1.0.0
Published
Cache-first and network-first request strategies on the browser Cache API — typed, size-capped, offline-aware. Degrades to network-only where caching is unavailable. Zero dependencies.
Maintainers
Readme
@dmytromykhailiuk/cache-request
Cache-first and network-first request strategies on the browser Cache API — typed, size-capped, offline-aware, dependency-free.
Full documentation: open Docs in a browser — every option, with examples, a table of contents and cross-links. This README is the short form.
⚠️ Nothing expires on its own. There is no TTL, by design: an entry stays under its key until
maxSizeevicts it, you delete it, or the browser reclaims the origin's storage. That is what you want for content that cannot change under a given URL — and not what you want for anything else. Version the key or the cache name ("assets-v3"), and usenetworkFirstwhenever the answer at a URL can change.
The Cache API is the right place to keep responses: it survives reloads, it is shared with your
service worker, and it stores real Response objects. What it does not give you is a strategy.
Every app ends up writing the same three-step dance by hand — look in the cache, call the network,
put the result back — and every hand-written version gets the edge cases slightly wrong: a body
read twice, an error response cached forever, a bucket that grows without a ceiling, a crash where
caches is simply not there.
This library is those two strategies, written once. You pass the key and the request; you get a
Response back — always a readable one, cloned before anything is stored.
Install
npm i @dmytromykhailiuk/cache-requestQuick start
import { cacheFirst, networkFirst } from "@dmytromykhailiuk/cache-request";
// Immutable under its URL: fetched once, then read from the cache forever.
const icon = await cacheFirst(iconUrl, () => fetch(iconUrl), {
cacheName: "assets-v1",
maxSize: 200,
});
// Live data that still has to render offline: the network wins while there is
// one, the last successful response takes over when there is not.
const response = await networkFirst("/api/profile", () => fetch("/api/profile"));
const profile = await response.json();The second argument is a function, not a promise, so the request is never started when the cache
can answer — and the request is yours: headers, AbortSignal, credentials, or something that is
not fetch at all, as long as it resolves to a Response.
API
type CacheFirstOptions =
| { cacheName?: undefined; maxSize?: never } // the shared default bucket
| { cacheName: string; maxSize?: number }; // your own bucket, optionally capped
type NetworkFirstOptions = CacheFirstOptions & {
isOnlineFn?: () => Promise<boolean> | boolean; // default: () => navigator.onLine
};
cacheFirst(key: string | Request, fn: () => Promise<Response>, options?: CacheFirstOptions): Promise<Response>
networkFirst(key: string | Request, fn: () => Promise<Response>, options?: NetworkFirstOptions): Promise<Response>cacheName— bucket in the origin'scachesstorage. Defaults to"general-cache", shared by every call that does not name one.maxSize— entry ceiling for that bucket, applied after every successful write; oldest write evicted first. Requires acacheNameof your own — the options type has nomaxSizewithout it, so it does not compile. See below.isOnlineFn— decides which branchnetworkFirsttakes. Called once per request, awaited when it returns a promise.
Choosing a strategy
| | cacheFirst | networkFirst |
| ---------------------- | -------------------------------- | ------------------------------------- |
| Reads the cache | always, first | only when offline |
| Calls fn | only on a miss | on every online call |
| Writes the cache | after a successful miss | after every successful call |
| Offline, entry present | serves it | serves it |
| Offline, no entry | calls fn, which fails as usual | throws Error("Offline and no cache") |
| Best for | hashed bundles, images, fonts | API reads that must survive offline |
If changing the content means changing the URL, use cacheFirst. If the same URL can answer
differently tomorrow, use networkFirst. Anything that must never be stale — payments,
permissions, one-time tokens — belongs in neither; call fetch directly.
A failed request is not offline. When isOnlineFn reports online, a rejected fn rejects the
call — a timeout, a DNS failure, an aborted signal stay your errors, and the cached entry is left
alone. Silently serving a cached answer in place of a failure hides outages.
maxSize needs a cacheName
The default "general-cache" bucket is never trimmed. It is shared by every call that did not
name a bucket, so one feature's limit would silently evict another feature's entries. A cap is a
statement about your bucket — and the types enforce it:
cacheFirst(url, load, { cacheName: "thumbnails", maxSize: 100 }); // ✓ capped
cacheFirst(url, load, { cacheName: "thumbnails" }); // ✓ uncapped
cacheFirst(url, load, { maxSize: 100 });
// ✗ Property 'cacheName' is missing in type '{ maxSize: number; }'One consequence: a value typed { cacheName?: string; maxSize?: number } is not assignable to
CacheFirstOptions, because that type permits exactly the combination the union rules out. Build
options as a literal at the call site, or type the variable as CacheFirstOptions.
Invalidation stays on the platform API — one line each, no wrapper needed:
await caches.delete("api"); // drop a bucket
const cache = await caches.open("api");
await cache.delete("/api/profile"); // invalidate one entrySafety
- The response is always readable. It is cloned before anything is stored and before you get it — from the network or from an entry already served a hundred times.
- Caching is best effort. No Cache API (SSR, Node, non-secure origins, some private modes),
blocked storage,
QuotaExceededError, a non-GET key, a 206 — the write is skipped and the request's own response is returned. Both strategies degrade to network-only rather than throwing. - Failures are never cached.
response.ok === falseis not stored, and neither are opaque cross-origin responses (status 0) — request them with CORS if they should be cached. - Keys match exactly, on the full URL: query-parameter order counts. A stored response's
Varyis honoured, so pass aRequestwhen the server varies by header.
Service workers
caches is the same storage in a window and in a service worker, and buckets are keyed by origin,
so both sides see the same entries:
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
if (new URL(request.url).pathname.startsWith("/assets/")) {
event.respondWith(
cacheFirst(request, () => fetch(request), { cacheName: "assets-v1", maxSize: 300 }),
);
}
});TypeScript
No generics to supply: both functions take a string | Request and resolve to a Response. The
exported types are CacheFirstOptions, NetworkFirstOptions and CacheRequestKey — useful for
wrappers that pass options through:
export const getJson = async <T>(url: string, options?: NetworkFirstOptions): Promise<T> => {
const response = await networkFirst(url, () => fetch(url), { cacheName: "api", ...options });
return (await response.json()) as T;
};License
MIT
