Cache and memoization
@dotpress/cache provides two tools, for two different problems:
Start with the memo: it saves queries without any risk of serving outdated data. Add the cache for values that are expensive to load and change rarely.
Set up
The plugin gives each request a memo, ctx.memo, and the cache, ctx.cache:
cachePlugin() without cache provides the memo only.
Memo
Load a value once per request
The authentication middleware loads the account of the user, and the handler needs it too. Declare how to load it, then ask the memo:
For GET /accounts/42/billing, the middleware and the handler both ask the memo for account 42: the database is queried once. They stay independent: neither has to pass the account to the other.
- Single flight: calls made while the value loads wait for the same load.
- Failures are not kept: a failed load fails the calls in progress, and the next call loads again.
- Keys: the arguments of
load, compared by value; akeyoption handles other arguments (key: (user) => user.id). - Values are not copies: a caller that mutates one changes it for the others.
- After an update,
ctx.memo.set(loadAccount, id, account)replaces the value, andctx.memo.delete(loadAccount, id)loads it again next time;memo.clear()forgets everything.
Batch the loads
A list of 50 articles shows the author of each. Loading the authors one by one makes 50 queries: the N+1 problem. With batch instead of load, the memo collects the keys requested in the same tick, and loads them in one call:
The code that renders one article does not change: it asks for one author, and the memo groups the requests. batch returns a Map by key, or an array in the order of the keys, where an Error fails its key only. maxBatchSize limits the keys per call.
One memo per unit of work
A memo lives as long as its request: each request starts empty, so it never serves outdated data. For the same reason, never keep a memo for the whole process: it would grow forever, and serve old values. In an event listener or a job, create one per message with createMemo() (see Outside a route).
Cache
Keep a value across requests
The plans of a billing service change a few times a year, and the service takes 300 ms to answer. Declare a cache entry, and read through it:
getOrSetreturns the cached value, or calls the loader and caches its result. Concurrent requests for a missing value share one load. A failed load, or a load returningundefined(not found), is not cached.get,setanddeleteare the usual operations.- Durations are seconds, or strings:
'500ms','30s','10m','1h','7d'. Thettlof an entry overrides thettlof the cache, 1 hour by default. - The
schematypes the value, and checks it when read: a value written by an older version of the app that no longer matches is a miss, not a crash. Values are stored as JSON: usez.coerce.date()for dates. - Keys are
prefix:name:v1:key; longer keys than the store accepts are hashed. Increase theversionof an entry when the shape of its values changes: the old values are ignored. - Values are serialized as JSON (or with a
serializer, such as superjson).nullis cached;undefinedis not.
Expire values when the data changes
A cached value is outdated as soon as the data changes. Delete it after an update:
When one change affects many values, tag them, and invalidate the tag:
Tags are computed from the arguments of the entry, not from the value: a value expires without being read. Reading a value with tags costs one more round trip to the store, for their tokens: see Tags.
Serve outdated values when the source fails
Some data is better a little outdated than missing. An entry decides how expired values are served:
When the store fails
The cache is an optimization: by default, it fails open. When the store is down, a read becomes a miss (the loader runs), a write is skipped, and the app keeps working. Every failure is logged, and passed to onError:
Set storeTimeout: while a Redis client reconnects, its commands wait, and the requests would wait with them. failOpen: false throws a CacheStoreError instead. onEvent receives each hit, miss and stale value, for metrics.
Stores
With several instances of the API, use a shared store: with the memory store, each instance has its own cache, and an invalidation only reaches one of them. The settings and limits of each store, Upstash and Workers KV included, are in the Cache reference.
Using both
The two tools combine: the cache keeps the result across requests, and the memo avoids repeated queries while computing it on a miss. In the Cache example (simplified here), the list of articles is cached for a minute; when it is computed, the authors are loaded with one batched query:
The example counts the queries of its fake database: its tests show what each tool saves.
Outside a route
Both tools work without an app, in a worker or a script:
Memo without passing the context
Services deep in the code do not always receive ctx. With memoScope: asyncLocalMemo, each request runs in the scope of its memo (AsyncLocalStorage), and a memoized loader works anywhere during the request:
In an event listener or a job, open a scope with runWithMemo(() => …): one memo per message. AsyncLocalStorage works on Node.js, Bun, Deno, and Cloudflare Workers with nodejs_compat. Outside a scope, a memoized loader throws: there is no global memo.