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-cacheRequires Waku 1.0.0-rc.4 or later and its React peer dependencies, including react-server-dom-webpack (React 19.3.1 for rc.4). 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.
Wrap a function; the cached wrapper accepts the same arguments and always returns a promise.
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.
If rendering fails anywhere in the subtree, the fill rejects without storing an entry, so the next request can retry.
Server Components inside a cached subtree must not call useId(): IDs generated by independent serialization streams can collide, and replaying the same entry also repeats its IDs. Pass explicit IDs as props and include them in the cache key, or move the ID-generating UI into a Client Component, where useId() uses the page's normal rendering context.
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 hash of the serialized 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. It stays stable while the entry is fresh. After invalidation or TTL expiry, it changes if the serialized bytes change. On a miss it's computed by rendering once (the same render the request needed anyway), so the tag matches the element being sent.
Keys are arrays of parts serialized with JSON.stringify (a bare string is shorthand for a one-part key).
Keys share a namespace across every wrapper using the same store. Include every input that affects the result, including user-specific inputs, and use distinct keys for function results and RSC subtrees.
Invalidation matches by prefix, so one call can drop a whole subtree of keys:
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— caches RSC pages, including nested Server Components and an interactive Client Component. It wiresgetEtagintogetConfig, invalidates throughPOST /invalidate, and caches function results containing Flight-supported values atGET /values.GET /retryandGET /retry?nesteddemonstrate recovery from root and nested render failures.
Run it:
pnpm compile
pnpm examples:01_minimalpnpm install
pnpm test
pnpm e2e
E2E_MODE=production pnpm e2eUnit tests cover both stores, concurrent fills, failure recovery, key encoding, and TTL boundaries. They use a serializer substitute at the Waku boundary. The e2e tests exercise Waku's real RSC serialization in development and production, including React's use() hook, hydration, render failure recovery, etag reuse, invalidation, TTL expiry, and non-JSON function results.
- Single-flight is per process. Concurrent misses collapse within one Node process; across processes you may compute the same value more than once.
- Invalidation removes stored entries. A fill already in progress may write its result afterward.
- 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.