Caching
Memoize expensive operations with Thunder's built-in cache utility - by TTL, scoped to an object's lifetime, or forever under your own key.
Thunder provides a lightweight cache utility that memoizes the result of an async function, so expensive work - database lookups, external API calls - doesn't run on every request.
Two functions do the work, both from @/core/utils/cache.ts - and cache takes either a TTL or an object:
| Function | Caches until | Keyed by |
|---|---|---|
cache(fn, ttl) | the TTL expires | the call's arguments, hashed automatically |
cache(fn, keyObject) | keyObject is garbage collected | the call's arguments, hashed automatically |
memoize(fn, keyFn) | forever | a key you compute |
Caching by Time
Wrap any async function with cache, passing a TTL in milliseconds:
import { cache } from "@/core/utils/cache.ts";
// Cache function results with a 1 minute TTL
const getUser = cache(async (id: string) => {
return await userModel.findOne({ _id: new ObjectId(id) });
}, 60_000);
// First call hits the database; subsequent calls within 60s return the cached value.
const user = await getUser("123");The arguments are hashed (with ohash), so every distinct argument list gets its own entry - getUser("123") and getUser("456") are cached separately.
Permanent cache
Use Infinity as the TTL to cache for the lifetime of the process - useful for configuration that never changes at runtime:
const getConfig = cache(async () => {
return await loadConfig();
}, Infinity);Caching for the Life of an Object
Pass an object instead of a TTL and the entries live exactly as long as that object stays reachable. When the object is collected, so is its cache.
The obvious use is a per-request cache: pass the Request itself, and the cache is cleaned up with the request.
const handler = (req: Request) => {
const getPermissions = cache(async (role: string) => {
return await permissionModel.find({ role }).toArray();
}, req); // Cleared once the request object is gone
};Now a permission lookup repeated five times while handling one request hits the database once, and nothing leaks between requests. The same trick scopes a cache to a tenant, a client instance, or any object whose lifetime you want the cache to follow.
Declaring this inside the handler is deliberate and safe - see Calling cache() inline is free below.
Caching Forever Under Your Own Key
memoize caches forever under a key you compute yourself. It skips the argument hashing entirely, so prefer it on hot paths that look up the same constant keys on every request:
import { memoize } from "@/core/utils/cache.ts";
export const findRouter = memoize(
async (globPattern: string, target: string) => {
/* ... */
},
(globPattern, target) => `${globPattern}\0${target}`,
);Choosing between them: reach for cache by default. Reach for memoize when the result never goes stale and the function is called often enough that hashing the arguments is itself worth avoiding.
Behaviour Worth Knowing
Calling cache() inline is free
cache() is deduplicated per (fn, ttl) or (fn, keyObject) pair. Calling it twice with the same function and the same TTL hands back the same cached function - so calling it inline inside a handler is safe and does not need hoisting to module scope.
// Both calls return the same cached function - not two separate caches.
const a = cache(loadRates, 60_000);
const b = cache(loadRates, 60_000);A different TTL is a separate cache.
Failures are not cached
A rejected promise is evicted from the cache, so failed async work retries on the next call instead of the rejection being served until it expires. This is true for both cache and memoize.
Nothing to clean up
The TTL sweep timer is unref'd and fn is held weakly, so a cache never keeps the process alive and never pins its function in memory. There is nothing to clear manually.
Two ways to get an error
Calling a keyObject-cached function after that object has been collected throws. And a second argument that is neither a number nor an object throws a TypeError.
Because Thunder is serverless-first, caches live only for the duration of a single execution context. For cross-request caching across instances, use an external store such as Redis.
Related
- Utilities - the rest of the built-in helpers
- Background Jobs - for work too slow to do in a request at all
- Database Indexes - often a better fix than caching a slow query