cache-request
cache-request 1.0.0

Cache-first and network-first request strategies on the browser Cache API — typed, size-capped, offline-aware, dependency-free.

Contents

Contents

cache-request

Two request strategies on top of the browser Cache APIcacheFirst 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.

two strategies size-capped buckets offline-aware degrades to network-only zero dependencies
Nothing expires on its own

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#

sh
npm i @dmytromykhailiuk/cache-request
Requirements

No runtime dependencies and no framework. Caching needs the Cache API, which every current browser exposes in a secure contexthttps:// 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.

ts
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#

ts
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.

tsavatars.ts
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#

cacheNamestring

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.

maxSizenumber

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#

ts
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.

tsthe offline branch is explicit
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();
}
A failed request is not offline

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#

cacheNamestring

Bucket to write to, and the only bucket consulted while offline. Defaults to "general-cache". See Cache names.

maxSizenumber

Entry ceiling for cacheName, applied after every successful write. Requires a cacheName of your own — the default bucket cannot be capped. See maxSize & eviction.

isOnlineFn() => Promise<boolean> | boolean

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:

tsoptional — any predicate will do
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.

ts
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:

ts
const request = new Request("/api/copy", {
  headers: { "Accept-Language": locale },   // server replies with Vary: Accept-Language
});

const response = await cacheFirst(request, () => fetch(request));
GET only

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:

tsno wrapper needed
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.

ts
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.
maxSize needs a cacheName of your own

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.

ts
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.

jssw.js
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.

ts
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:

tsapi.ts
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