A server cache for Waku. Two primitives — cache a function and cache an RSC subtree — over a tiny, swappable storage interface.
The cache is explicit: nothing is cached unless you wrap it, you choose each entry's key, and you invalidate through a method on your own cache instance. Keys are arrays, TanStack-Query style, and invalidation matches by prefix.
npm install waku-cachereact and waku are peer dependencies; the library itself has no runtime dependencies. The unstorage store additionally needs unstorage (an optional peer dependency); the memory store needs nothing.
Create one cache instance for your app:
// src/lib/cache.ts
import { createCache } from 'waku-cache';
import { memoryStore } from 'waku-cache/stores/memory';
export const cache = createCache({
store: memoryStore(),
defaults: { ttl: 60_000 }, // optional default TTL in ms
});Then reach for one of two primitives.
The common case. Wrap an async function; the call signature is unchanged, so it's a drop-in.
import { cache } from './lib/cache.js';
const getProduct = cache.fn(
async (id: string) => db.products.findUnique({ where: { id } }),
{
key: (id) => ['product', id], // a function of the arguments
ttl: 5 * 60_000, // optional, overrides defaults.ttl
},
);
const product = await getProduct('abc'); // identical to the unwrapped callkeyis a function of the arguments, so it stays type-checked.- Concurrent misses for the same key are single-flighted within a process, so a burst of callers triggers one computation, not N.
- Values are serialized with Waku's own
serializeRsc, soDate,Map,Set,BigInt,undefined— anything React Flight handles — round-trip, with no extra dependency. (null/undefinedresults are cached too, so "not found" doesn't hit your database every time.)
Wrap a Server Component; its rendered subtree is serialized to RSC bytes once and replayed on later requests. A cached subtree is a single opaque blob to its parent, but the tree inside can still call other cached functions on a miss — composition works naturally.
cache.rsc returns a { Component, getEtag } pair:
// src/pages/product.tsx
import { cache } from '../lib/cache.js';
const ProductCard = async ({ id }: { id: string }) => {
const product = await getProduct(id);
return <article>{product.name}</article>;
};
const { Component, getEtag } = cache.rsc(ProductCard, {
key: ({ id }) => ['ProductCard', id],
ttl: 60_000,
});
export default Component;The base capability above — serialize once, replay on hit — already saves the server from re-rendering. getEtag is an optional optimization on top. Wire it into getConfig's unstable_getEtag and Waku will also skip re-sending the slot on the wire during client navigation, for as long as the tag is unchanged:
export const getConfig = () => {
return {
render: 'dynamic',
unstable_getEtag: getEtag, // delete this line and the render cache still works
} as const;
};The etag is a content hash of the cached subtree — just a label for what the client currently holds. On each request the server compares it with the client's and decides whether to re-send the element or let the client reuse its cache. So it changes exactly when the content changes — on invalidation and on TTL expiry — and stays stable while the entry is fresh. On a miss it's computed by rendering once (the same render the request needed anyway), so the tag always matches the element being sent.
Keys are arrays of parts, like TanStack Query (a bare string is shorthand for a one-part key). Invalidation matches by prefix, so one call can drop a whole subtree of keys — this is how you express grouped/cross-cutting invalidation without a separate "tags" concept:
const getProduct = cache.fn(load, { key: (id) => ['product', id] });
const ProductCard = cache.rsc(Card, {
key: ({ id }) => ['product', id, 'card'],
});
await cache.invalidate({ key: ['product', 'abc'] }); // this product + its card
await cache.invalidate({ key: ['product'] }); // every product entryPrefix matching is structural at part boundaries: ['product'] covers ['product', 'abc'] but never ['production'].
A common place to call invalidate is wherever you handle a mutation — for example, an API route (as the example app does):
// src/pages/_api/revalidate.ts
import { cache } from '../../lib/cache.js';
export async function POST(req: Request) {
const { id } = await req.json();
await cache.invalidate({ key: ['product', id] });
return new Response('ok');
}A store is the only thing you must choose. The contract is three methods (see Bring your own); two are built in.
import { memoryStore } from 'waku-cache/stores/memory';
createCache({ store: memoryStore() });A Map with lazy TTL expiry on read, and in-memory partial-match invalidation. Zero dependencies. Perfect for dev, tests, and small single-node servers. State lives for the process lifetime.
import { createStorage } from 'unstorage';
import redisDriver from 'unstorage/drivers/redis';
import { unstorageStore } from 'waku-cache/stores/unstorage';
const storage = createStorage({
driver: redisDriver({ url: process.env.REDIS_URL }),
});
createCache({ store: unstorageStore(storage) });Adapts any unstorage Storage into a cache store, so a single adapter reaches redis, Cloudflare KV/R2, Vercel KV, Upstash, filesystem, S3, MongoDB, PlanetScale, and more — just swap the driver:
import cloudflareKVBinding from 'unstorage/drivers/cloudflare-kv-binding';
import vercelKV from 'unstorage/drivers/vercel-kv';
unstorageStore(
createStorage({ driver: cloudflareKVBinding({ binding: env.KV }) }),
);
unstorageStore(createStorage({ driver: vercelKV() }));Notes:
- RSC bytes are base64-encoded inside a JSON entry, so even string-only drivers work.
- Native TTL is passed through to drivers that support it; expiry is also enforced lazily on read for those that don't.
- Invalidation maps the key to a
:-separated unstorage key and uses the backend's nativegetKeys(base)(e.g. a RedisSCAN MATCH), so it lists only the matching subtree, not every key.
The interface is intentionally tiny. Keys arrive as arrays of already-encoded, separator-safe segments; the store joins/indexes them however it likes (memoryStore joins with : and partial-matches; unstorageStore maps them to :-separated unstorage keys):
interface CacheStore {
get(key: readonly string[]): Promise<CacheEntry | null>;
set(key: readonly string[], entry: CacheEntry): Promise<void>;
// remove the entry at `prefix` and every entry whose key has it as a leading
// sub-array (TanStack-style partial match)
delete(prefix: readonly string[]): Promise<void>;
}
interface CacheEntry {
value: Uint8Array; // serialized RSC bytes
createdAt: number;
expiresAt?: number; // absolute epoch ms; `get` must return null once past it
}examples/01_minimal— a Waku app caching RSC pages withcache.rsc, wiring the opt-ingetEtagintogetConfig, and invalidating through an/invalidateAPI route. The e2e (e2e/01_minimal.spec.ts) asserts the server render cache (a fresh client is served the stored payload), the etag wire-skip on navigation, the re-send after invalidation, and a TTL page where the element is skipped while fresh and re-sent after expiry.
Run it:
pnpm compile
pnpm examples:01_minimal- Single-flight is per process. Concurrent misses collapse within one Node process; across processes you may compute the same value more than once.
- Invalidation uses prefix matching, not a prefix index — the same model as TanStack Query (partial key matching).
memoryStoredoes an in-memory partial-match over itsMap(local, like TanStack).unstorageStoremaps keys to:-separated unstorage keys and uses the backend's nativegetKeys(base)(e.g. a RedisSCAN MATCH), so it lists only the matching subtree rather than every key — though on a huge keyspace a server-sideSCANis still not O(matches); a dedicated secondary index would be.