npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 maxSize evicts 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 use networkFirst whenever 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-request

Quick 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's caches storage. 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 a cacheName of your own — the options type has no maxSize without it, so it does not compile. See below.
  • isOnlineFn — decides which branch networkFirst takes. 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 entry

Safety

  • 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 === false is 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 Vary is honoured, so pass a Request when 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