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:

FunctionCaches untilKeyed by
cache(fn, ttl)the TTL expiresthe call's arguments, hashed automatically
cache(fn, keyObject)keyObject is garbage collectedthe call's arguments, hashed automatically
memoize(fn, keyFn)forevera 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.



On this page