Skip to content

About

Cache for React Server Components in Waku

Resources

Stars

7 stars

Watchers

1 watching

Forks

Repository files navigation

waku-cache

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.

Install

npm install waku-cache

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

Quick start

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.

cache.fn — cache a function

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 call
  • key is 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, so Date, Map, Set, BigInt, undefined — anything React Flight handles — round-trip, with no extra dependency. (null/undefined results are cached too, so "not found" doesn't hit your database every time.)

cache.rsc — cache an RSC subtree

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 etag is opt-in

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 and invalidation

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 entry

Prefix 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');
}

Stores

A store is the only thing you must choose. The contract is three methods (see Bring your own); two are built in.

memoryStore()

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.

unstorageStore(storage)

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 native getKeys(base) (e.g. a Redis SCAN MATCH), so it lists only the matching subtree, not every key.

Bring your own store

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

  • examples/01_minimal — caches RSC pages, including nested Server Components and an interactive Client Component. It wires getEtag into getConfig, invalidates through POST /invalidate, and caches function results containing Flight-supported values at GET /values. GET /retry and GET /retry?nested demonstrate recovery from root and nested render failures.

Run it:

pnpm compile
pnpm examples:01_minimal

Testing

pnpm install
pnpm test
pnpm e2e
E2E_MODE=production pnpm e2e

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

Limitations

  • 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). memoryStore does an in-memory partial-match over its Map (local, like TanStack). unstorageStore maps keys to :-separated unstorage keys and uses the backend's native getKeys(base) (e.g. a Redis SCAN MATCH), so it lists only the matching subtree rather than every key — though on a huge keyspace a server-side SCAN is still not O(matches); a dedicated secondary index would be.

About

Cache for React Server Components in Waku

Resources

Stars

7 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages