npm.io
2.0.1 • Published 2d ago

biscuit-cache-js

Licence
MIT
Version
2.0.1
Deps
0
Size
92 kB
Vulns
0
Weekly
0

Biscuit.js

Memory-first, persistent, reactive browser cache with background refresh & cross-tab sync.


Features

  • Memory-first – Instant reads from a fast in-memory Map
  • Persistent – Falls back to IndexedDB for reload-safe storage
  • Reactive – Subscribe to updates for live UI state
  • TTL & Auto-Expiry – Data expires automatically after configurable ttl
  • Background Refresh – Optionally re-fetch data before it goes stale, with configurable per-key refresh policies
  • Cross-Tab Sync – Stays in sync across browser tabs/windows
  • Namespaces – Run multiple isolated stores side by side
  • Resilient – Retries with backoff, fetch timeouts, and abortable fetchers out of the box
  • SSR-safe – Falls back to memory-only automatically in Node/SSR, no crashes
  • Tiny & Dependency-free – Pure JavaScript, no React or framework lock-in (React hooks available as an optional add-on)

Installation

npm install biscuit-cache-js

Zero required dependencies. React is fully optional — the core package never imports it, so biscuit-cache-js runs the same core API in a Node backend, a vanilla-JS frontend, or a React app (Node/SSR automatically falls back to memory-only, since there's no IndexedDB to persist to).

Note on peerDependencies: package.json lists react as an

optional peer dependency. That requirement only applies if you import from biscuit-cache-js/react (i.e. you use useBiscuit or useBiscuitAll). If you only import from biscuit-cache-js itself, react doesn't need to be installed at all — npm/yarn/pnpm may still print an "optional peer dependency not installed" notice in that case, which is expected and safe to ignore.


Quick Start

import Biscuit from "biscuit-cache-js";

async function main() {
  // 1️⃣ Store data with TTL (5 min default)
  await Biscuit.set("user", { id: 1, name: "Martins" });

  // 2️⃣ Retrieve data
  console.log(await Biscuit.get("user"));
  // => { id: 1, name: "Martins" }

  // 3️⃣ Subscribe to updates
  const unsubscribe = Biscuit.subscribe(state => {
    console.log("Updated Biscuit state:", state);
  });

  // 4️⃣ Mutate in-place
  await Biscuit.mutate("user", user => ({ ...user, name: "Kelvin" }));

  // 5️⃣ Remove data
  await Biscuit.remove("user");

  unsubscribe();
}

main();

Biscuit (the default export) is a ready-to-use instance. For isolated stores, use createBiscuit() — see Namespaces below.


Namespaces

Need more than one independent cache (e.g. cart vs. user session)? Each createBiscuit() call gets its own in-memory jar and its own IndexedDB database.

import { createBiscuit } from "biscuit-cache-js";

const CartStore = createBiscuit({ namespace: "cart" });
const UserStore = createBiscuit({ namespace: "user" });

await CartStore.set("items", ["Apple", "Banana"]);
await UserStore.set("user", { id: "123", name: "Martins" });

TTL + Background Refresh

async function fetchFriends() {
  const res = await fetch("/api/friends");
  return res.json();
}

// Cache with 1 hour TTL + auto-refresh using the fetcher
await Biscuit.set("friends", await fetchFriends(), 3_600_000, fetchFriends);

Biscuit will:

  • Serve data instantly from memory
  • Refresh data in the background starting at ~90% of the TTL (10% before expiry)
  • Keep all tabs synced automatically
Refresh policies

Control when a key's fetcher runs via the 5th argument to set():

await Biscuit.set(key, value, ttl, fetcher, { refreshPolicy: "on-demand" });
Policy Behavior
"background" (default) Proactively refreshes before expiry — the key rarely goes stale
"on-demand" Never runs ahead of time. Fetches only when the key is actually read after expiring — and get() still returns instantly with the stale value while the fetch happens in the background, so reads are never blocked by the network. Pass { blocking: true } to get() if a specific read genuinely needs to wait for a fresh value.
"never" Fully manual — only an explicit refresh() or invalidate() call updates it

Cross-Tab Sync

Open two tabs of your app. Updating Biscuit in one tab updates the other automatically — including a full clear().

await Biscuit.set("theme", "dark");
// Other tabs get notified instantly 🎉

Cache Invalidation

Force a key (or a whole group of keys) to refresh on next access:

await Biscuit.invalidate("friends");           // one key
await Biscuit.invalidatePattern("friends-*");   // wildcard, or pass a RegExp

Batch Operations

await Biscuit.setMany([
  { key: "a", value: 1, ttl: 60_000 },
  { key: "b", value: 2, ttl: 60_000 },
]);

const { a, b } = await Biscuit.getMany(["a", "b"]);

Waiting for a Value

Resolve as soon as a key has data — immediately if it's already cached, or the first time it's set/refreshed otherwise:

const user = await Biscuit.waitFor("user", { timeout: 5000 });

Resilience

Biscuit is built to fail safely rather than take your app down with it:

const cache = createBiscuit({
  maxRetries: 2,                                  // retry a failed background refresh
  retryDelay: attempt => Math.min(500 * 2 ** attempt, 10_000), // backoff
  fetchTimeout: 8000,                              // give up waiting on a slow fetcher
  onError: (error, context) => reportToSentry(error, context), // pipe failures to your telemetry
});

Fetchers can optionally accept an AbortSignal to support real cancellation on timeout or destroy():

const fetchUser = (signal) => fetch("/api/user", { signal }).then(r => r.json());

Cancellation only stops your app from waiting on the request — it can't undo something a server already did. If a fetcher wraps a mutation rather than a read, use an idempotency key on your backend so a client-side retry can't double-apply it.


Works in Node / SSR

No indexedDB or window? Biscuit detects this automatically and runs memory-only instead of throwing — safe to import in a Next.js/Node context.


React Hooks (optional)

import { useBiscuit } from "biscuit-cache-js/react";

function Profile() {
  const [user, { loading, refresh }] = useBiscuit(Biscuit, "user:42", {
    fetcher: () => fetchUser(42),
    ttl: 60_000,
  });
  if (loading) return <Spinner />;
  return <div>{user?.name}</div>;
}

Requires react as a peer dependency — everything else works without it.


API Reference

Method Description
set(key, value, ttl?, fetcher?, options?) Store data with optional TTL (ms), background-refresh fetcher, and { refreshPolicy }
get(key, { extend?, staleWhileRevalidate?, blocking? }) Retrieve data
mutate(key, mutator) Safely update an existing value in place
remove(key) Delete a single entry
clear() Clear all entries (propagates to other tabs)
has(key) Check if a key exists and is still fresh
keys() / size(options?) Inspect what's cached
subscribe(fn) / subscribeKey(key, fn) Listen for changes, returns an unsubscribe function
refresh(key) Force-refresh a key now (ignores TTL)
invalidate(key) / invalidatePattern(pattern) Force a key (or matching keys) to refresh on next access
setMany(items) / getMany(keys, options?) Batch read/write
waitFor(key, { timeout? }) Resolve once a key has a value
estimateUsage() Best-effort storage usage ({ usage, quota, percent })
registerFetcher(id, fn) / getMissingFetcherIds() Rebind fetchers after a reload
isOnline() Whether Biscuit currently thinks the browser is online
enableDebug() / disableDebug() Toggle verbose console logging
inspect() Dump internal state for debugging
destroy() Tear down the instance and free resources

Why Biscuit?

Think of Biscuit as localStorage + Reactivity + TTL + Background Refresh. It removes the pain of:

  • Manual cache invalidation
  • Re-fetching data too often
  • Keeping multiple tabs in sync
  • Losing data on page reload

Examples

See the examples/ folder for more use cases — basic usage, namespaces, debug logging, and a full feature walkthrough.


The Story Behind Biscuit

This idea for a caching system came to me from the Holy Spirit. I brought it to ChatGPT first to work out the shape of it, then to Claude to push it further — persistence, background refresh, cross-tab sync, and eventually a React layer, built out piece by piece across a lot of back-and-forth.

None of it was obvious going in. Getting cross-tab sync actually correct, making background refresh behave when a tab's been backgrounded for a while, deciding what a cache should do when IndexedDB itself fails — those took real iteration, not a single lucky prompt.

Biscuit is what came out of that: a cache that acts like localStorage, but doesn't lose your data on reload, doesn't go stale silently, and doesn't fall out of sync the moment you open a second tab.

Also by me: executor-fn — a function wrapper with state, history, and undo/redo built in.


License

MIT Martins Kelvin

Keywords