cache-request
Two request strategies on top of the browser
Cache API — cacheFirst and
networkFirst — in a few hundred bytes, with no
dependencies.
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 rather than parsed copies of them.
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 in Safari's private mode 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. Buckets are named and can be
size-capped, offline is a first-class
branch rather than an exception, and where caching is unavailable
the call quietly becomes a plain network request instead of
throwing.
There is no TTL here, by design. An entry stays under its key
until it is evicted by maxSize, deleted
by you, or cleared with the origin's storage — so
cacheFirst will keep serving the same response for
as long as the browser keeps it. That is exactly what you want
for content that cannot change under a given URL, and exactly
what you do not want for anything else. Put the version in the
key or in the cache name
("assets-v3"), and reach for
networkFirst whenever the answer at a URL can change
— it refreshes the entry on every online call and falls back to
it only when there is no network at all.
Getting started
Install#
npm i @dmytromykhailiuk/cache-request
No runtime dependencies and no framework. Caching needs the
Cache API, which every current browser exposes
in a secure context — https:// or
localhost, in a window or a service worker. Where it
is missing, both functions still run: see
When caching is skipped. Ships ESM and CJS
with type declarations for both.
Quick start#
Both functions take the same three arguments: a key, a function
that performs the request, and options. Both resolve to a
Response.
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: add headers, an
AbortSignal, credentials, or call something that is
not fetch at all, as long as it resolves to a
Response.
Concepts
Choosing a strategy#
The difference is not how fast they are — it is
which answer wins when the two disagree.
cacheFirst trusts the stored copy;
networkFirst trusts the network and keeps the stored
copy as a safety net.
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 |
| Freshness | whatever was stored first | always current while online |
| Offline, entry present | serves it | serves it |
| Offline, no entry | calls fn, which fails as it normally would |
throws Error("Offline and no cache") |
| Best for | hashed bundles, images, fonts, static reference data | API reads that must survive going offline |
A rule of thumb: if changing the content means changing the URL,
use cacheFirst. If the same URL can answer differently
tomorrow, use networkFirst. Anything that must not be
served stale — payments, permissions, one-time tokens — belongs in
neither; call fetch directly.
API
cacheFirst#
cacheFirst(
key: string | Request,
fn: () => Promise<Response>,
options?:
| { cacheName?: undefined } // the shared default bucket
| { cacheName: string; maxSize?: number }, // your own bucket, optionally capped
): Promise<Response>
Looks the key up in the bucket. On a hit it
returns the stored response and fn is never called. On
a miss it awaits fn, stores the
response when it is
successful, and returns it.
export const loadAvatar = async (userId: string) => {
const url = `/avatars/${userId}.png`;
const response = await cacheFirst(url, () => fetch(url), {
cacheName: "avatars",
maxSize: 300,
});
return URL.createObjectURL(await response.blob());
};
Because a hit skips fn entirely, the answer can arrive
without a network round trip at all — that is the point of the
strategy, and also its cost: a stale entry is never noticed. When
the content behind a URL can change, either version the URL, or use
networkFirst.
Options#
Bucket to read from and write to. Defaults to
"general-cache", the shared bucket used by every
call that does not name one. See
Cache names.
Maximum number of entries kept in cacheName;
unlimited by default. Once the bucket grows past it, the
oldest writes are evicted.
Requires a cacheName of your own — passing
maxSize alone does not compile, and the default
bucket is never trimmed at runtime either. See
maxSize & eviction.
networkFirst#
networkFirst(
key: string | Request,
fn: () => Promise<Response>,
options?: (
| { cacheName?: undefined } // the shared default bucket
| { cacheName: string; maxSize?: number } // your own bucket, optionally capped
) & {
isOnlineFn?: () => Promise<boolean> | boolean;
},
): Promise<Response>
Asks isOnlineFn first.
Online: awaits fn, refreshes the
stored entry when the response is
successful, and returns it — the cache is
written on the way out, never read.
Offline: returns the stored entry without touching
the network, or throws
Error("Offline and no cache") when there is none.
try {
const response = await networkFirst("/api/orders", () => fetch("/api/orders"), {
cacheName: "api",
});
render(await response.json(), { stale: !navigator.onLine });
} catch (error) {
// offline and this endpoint was never loaded before
renderEmptyState();
}
When isOnlineFn reports online, a rejected
fn rejects the call — a timeout, a DNS failure, an
aborted signal and a 500 stay your errors, and the
cached entry is left alone. That is deliberate: silently serving
a cached answer in place of a failure hides outages, and turns a
bug you could have seen into data that looks current. If a
particular endpoint should degrade to its last known answer,
catch the error and read the cache yourself — or move that
endpoint to cacheFirst.
Options#
Bucket to write to, and the only bucket consulted while
offline. Defaults to "general-cache". See
Cache names.
Entry ceiling for cacheName, applied after every
successful write. Requires a cacheName of your
own — the default bucket cannot be capped. See
maxSize & eviction.
Decides which branch runs. Called on every request, awaited
when it returns a promise. Defaults to
() => navigator.onLine. See
The online check.
The online check#
The default is navigator.onLine, and it is worth
knowing exactly what that means: it is false only when
the device has no link at all. A captive portal, a dead uplink, a
VPN that dropped, an offline PWA restored from the back-forward
cache — in all of those it happily reports true, the
request goes out, and it fails. Which is
an error you see, not a silent
fallback — but it is not the offline branch either.
Anything that answers the question better can be passed instead. A
healthcheck-backed probe, for example, is what the sibling
network-connection package exists for:
import { NetworkConnection } from "@dmytromykhailiuk/network-connection";
import { networkFirst } from "@dmytromykhailiuk/cache-request";
await NetworkConnection.init({ healthcheckUrl: "/healthz" });
const request = (url: string) =>
networkFirst(url, () => fetch(url), {
cacheName: "api",
isOnlineFn: () => NetworkConnection.isOnline,
});
The probe is called once per request and its result is not cached, so keep it cheap — read a flag you already maintain rather than issuing a network call of its own.
Storage
Cache keys#
The key is anything the Cache API accepts — a URL string or a
Request. A string is resolved against the current
document, so "/api/profile" and the absolute URL it
expands to are the same entry.
await cacheFirst("/logo.svg", () => fetch("/logo.svg")); // by URL
await cacheFirst(request, () => fetch(request)); // by Request
// Two entries, not one — the strings differ, so the keys differ.
await cacheFirst("/api/list?page=1&sort=asc", load);
await cacheFirst("/api/list?sort=asc&page=1", load);
Matching is exact, on the full URL: query parameters count, and
their order counts too. Build the key the same way every time —
ideally the same string you hand to fetch — or
normalize it once in a helper. Fragments (#…) are
ignored by the Cache API, as they are by the network.
A key can also carry the shape of the request rather than just its
address. When the server varies its answer by header, the stored
response's Vary is honoured on lookup, so passing a
Request with those headers is what keeps the two
variants apart:
const request = new Request("/api/copy", {
headers: { "Accept-Language": locale }, // server replies with Vary: Accept-Language
});
const response = await cacheFirst(request, () => fetch(request));
The Cache API refuses to store an entry keyed by a
POST, PUT or DELETE
request. Passing one is not an error here — the write is
skipped and the response comes back
untouched — but nothing will ever be cached, so a mutation is
better sent with plain fetch. Sending it through
networkFirst is worse than useless: while offline it
would answer from a cache it can never fill.
Cache names#
A cache name is a bucket in the origin's
caches storage. Calls that name the same bucket share
entries; calls that name different buckets cannot see each other's.
Without cacheName everything lands in
"general-cache".
Naming buckets per concern is what makes them manageable, because the interesting operations are the ones this library deliberately does not wrap — they are one line of platform API each:
await caches.delete("api"); // drop a bucket entirely
const cache = await caches.open("api");
await cache.delete("/api/profile"); // invalidate one entry
(await cache.keys()).length; // how full is it
await caches.keys(); // every bucket on the origin
Put the version in the name whenever the meaning of the stored
entries can change — "assets-v3",
"api-v2". A deploy that renames the bucket starts from
an empty one, and the old bucket can be dropped in a single
caches.delete instead of being invalidated key by key.
maxSize & eviction#
maxSize caps how many entries a bucket keeps. After
every successful write the bucket is trimmed back to the limit,
oldest write first — and re-caching an existing key counts as a new
write, so the entries that survive are the ones most recently
stored.
const thumbnail = (url: string) =>
cacheFirst(url, () => fetch(url), { cacheName: "thumbnails", maxSize: 100 });
// 101st distinct URL: the oldest stored thumbnail is deleted, not the
// least recently *read* one — a hit does not rewrite the entry.
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 limit nobody declared, deleting data
nobody expected to lose. A cap is a statement about
your bucket, so it takes a cacheName to go
with it, and the types say so: maxSize only exists on
the branch of the options type that requires one. Pass both, or
neither.
cacheFirst(url, load, { cacheName: "thumbnails", maxSize: 100 }); // ✓
cacheFirst(url, load, { cacheName: "thumbnails" }); // ✓ uncapped
cacheFirst(url, load, { maxSize: 100 });
// ✗ Property 'cacheName' is missing in type '{ maxSize: number; }'
Counting entries is not the same as counting bytes: a hundred
thumbnails and a hundred videos both hit a
maxSize of 100 at very different sizes. Set the limit
per bucket, with the size of the things in it in mind — and note
that the browser can still evict the whole origin under storage
pressure, so a cache entry is never a guarantee, only a very likely
shortcut.
Behaviour
When caching is skipped#
Caching is best effort, always. Every case below skips the write and returns the response the request produced — a cache that cannot store something is not a reason to fail a request that already succeeded.
| Case | What happens |
|---|---|
response.ok is false |
Not stored. 4xx and 5xx are answers, not content — caching them would pin an outage in place. |
| An opaque response |
Not stored. A cross-origin no-cors request has
status 0, so it never counts as
successful; request it with CORS if it should be cached.
|
No Cache API |
Both strategies become network-only: SSR and Node, non-secure origins, some private-browsing modes. Nothing is read, nothing is written, nothing throws. |
| Storage is blocked or the quota is full |
The write fails and is swallowed —
QuotaExceededError, a disabled-storage policy,
a sandboxed iframe.
|
| A non-GET key, or a 206 partial response | The Cache API rejects these; the response is returned unchanged. See Cache keys. |
| Reading the cache fails |
Treated as a miss: cacheFirst goes to the
network, networkFirst throws
Offline and no cache if it was offline.
|
The one thing that is never skipped is the clone. The response is duplicated before anything is stored and before you get it, so the body you receive is always unread — whether it came from the network or from a cache entry that has already been served a hundred times.
Service workers#
caches is the same storage in a window and in a
service worker, and buckets are keyed by origin — so a bucket
filled from the page is the one the worker reads, and the other way
around. Both functions run unchanged in either place.
import { cacheFirst, networkFirst } from "@dmytromykhailiuk/cache-request";
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.pathname.startsWith("/assets/")) {
event.respondWith(
cacheFirst(request, () => fetch(request), {
cacheName: "assets-v1",
maxSize: 300,
}),
);
return;
}
if (url.pathname.startsWith("/api/")) {
event.respondWith(
networkFirst(request, () => fetch(request), { cacheName: "api-v1" }),
);
}
});
Two details worth keeping in mind inside a worker.
event.respondWith wants a Response and a
rejection turns into a network error for the page — so the offline
branch of networkFirst is something to catch and
answer, typically with a cached shell. And the worker's
navigator.onLine is the same coarse signal it is on
the page; if you already track connectivity properly, pass it as
isOnlineFn.
Reference
TypeScript#
There are no generics to supply: both functions take a
string | Request and resolve to a
Response. The types worth importing are the option
objects, for wrappers that pass options through.
import type {
CacheFirstOptions,
CacheRequestKey,
NetworkFirstOptions,
} from "@dmytromykhailiuk/cache-request";
type CacheRequestKey = string | Request;
type CacheFirstOptions =
| { cacheName?: undefined; maxSize?: never }
| { cacheName: string; maxSize?: number };
type NetworkFirstOptions = CacheFirstOptions & {
isOnlineFn?: () => Promise<boolean> | boolean;
};
CacheFirstOptions is a union rather than one flat
object for a single reason: it is what makes
maxSize without a
cacheName fail to compile. A consequence worth
knowing — a value typed
{ cacheName?: string; maxSize?: number } is not
assignable to it, 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.
A typed reader is a three-line wrapper — the generic belongs to the body you parse, not to the strategy that fetched it:
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;
};
const profile = await getJson<Profile>("/api/profile");
Exports#
Values
cacheFirst · networkFirst
Types
CacheFirstOptions ·
NetworkFirstOptions · CacheRequestKey