Skip to content
Warlock.js v4

cached()

cached() is a higher-order function that wraps any async function with a cache. Declare the caching strategy once; every subsequent call uses the cache automatically.

import { cached } from "@warlock.js/cache";
const getUser = cached(
(id: number) => db.users.find(id),
"user",
"1h",
);
await getUser(42); // cache miss → runs DB query, caches, returns
await getUser(42); // cache hit → no DB call
await getUser.invalidate(42); // drop "user.42" from the cache
await getUser(42); // miss again

Uses remember() under the hood — inherits stampede protection for free.

TaskUse
One-shot “get this value or compute it”cache.remember()
Turn an existing function into a cached version you’ll call many timescached()

All three put fn first and return the same wrapped function type.

Driver’s default TTL. The wrapped function’s arguments are auto-appended to the prefix to derive the cache key.

const getFeatured = cached(
() => db.products.findFeatured(),
"featured-products",
);
// cache key: "featured-products"
const getUser = cached(
(id: number) => db.users.find(id),
"user",
);
// getUser(42) → cache key "user.42"

Same as #1, with an explicit TTL. Accepts number of seconds or a duration string.

const getUser = cached(
(id: number) => db.users.find(id),
"user",
"1h",
);
const getOrder = cached(
(userId: number, orderId: string) => db.orders.find(userId, orderId),
"orders",
"10m",
);
// getOrder(42, "abc") → cache key "orders.42.abc"

Custom key function, tags, per-call driver override.

type Filters = { category: string; sort: string; page: number };
const searchProducts = cached(
(filters: Filters) => db.products.search(filters),
{
key: (filters) => `products.search.${filters.category}.${filters.sort}`,
ttl: "15m",
tags: ["products"],
driver: "redis",
},
);

Use the options form when:

  • The cache key shouldn’t include every argument (drop page from the key above so paginated calls share a cache entry).
  • You want to attach tags for bulk invalidation.
  • You need to route this wrapper’s calls to a non-default driver.

When you use the positional cached(fn, prefix) / cached(fn, prefix, ttl) form, keys are derived automatically:

Args shapeKey
No argsprefix
All primitives (string, number, boolean, bigint, null, undefined)${prefix}.${args.join(".")}
Any object / array arg${prefix}.${JSON.stringify(args)}
Serialization throws (circular refs, BigInt nested in an object)CacheConfigurationError

Examples:

getUser(42) // "user.42"
getUser("john") // "user.john"
getOrder(42, "abc") // "orders.42.abc"
getBy("john", null) // "user.john.null"
searchBy({ q: "hello" }) // 'search.[{"q":"hello"}]'
  • Order matters. getOrder(42, "abc") and getOrder("abc", 42) produce different keys.
  • Date → ISO string via JSON.stringify. Two Date objects at the same millisecond share a key — correct.
  • Map / Set serialize to {} via JSON.stringify — that’s a footgun. Reach for the options form with a custom key fn.
  • Very large args produce very large keys. Fine on Redis, terrible on file driver (absurd directory paths).

When the auto-key isn’t right, use the options form. key can project whatever subset of the args actually identifies the cached value.

Drop the cache entry for a specific argument combination — uses the same key function the wrapper uses internally, so callers don’t re-derive keys by hand.

const getUser = cached((id: number) => db.users.find(id), "user", "1h");
await getUser(42); // populates "user.42"
await getUser.invalidate(42); // drops "user.42"

:::info Planned for v2.1 .refresh() (force-recompute) and .peek() (read without populating) are not shipping yet. If you need them, file it under domains/cache/backlog.md. :::

const getUser = cached(
(id: number) => db.users.findById(id),
"user",
"1h",
);
// Call wherever you need a user
const user = await getUser(42);
// Invalidate after a write
await db.users.update(42, patch);
await getUser.invalidate(42);
const getUser = cached(
(id: number) => db.users.findById(id),
{ key: (id) => `user.${id}`, ttl: "1h", tags: ["users"] },
);
const getPosts = cached(
(userId: number) => db.posts.findByUser(userId),
{ key: (userId) => `posts.by-user.${userId}`, ttl: "30m", tags: ["users", "posts"] },
);
// After any user-table change, drop every wrapper's cache in one go
await cache.tags(["users"]).invalidate();
type Filters = { category: string; sort: string; page: number; pageSize: number };
// Key ignores `page` so paginated calls for the same filters share an entry.
// (Only do this if your source function returns ALL pages — otherwise pages
// would collide. Use this pattern thoughtfully.)
const getCategoryMeta = cached(
(filters: Filters) => db.categories.meta(filters.category),
{
key: (f) => `category.meta.${f.category}`,
ttl: "1h",
},
);
const getWeather = cached(
(city: string) => fetch(`https://api.weather.com/${city}`).then((r) => r.json()),
{ key: (city) => `weather.${city.toLowerCase()}`, ttl: "10m", tags: ["weather"] },
);
// Refresh everyone after a scheduled sync
cron.every("10m", () => cache.tags(["weather"]).invalidate());

Inherited from remember(). When 100 concurrent calls hit the same wrapper with the same args on a cold cache, the underlying function runs once and the other 99 share its promise. Works within a single Node process; cross-process stampede safety still needs a distributed lock.

const getter = cached(expensiveQuery, "report", "1h");
// Underlying expensiveQuery runs exactly once
await Promise.all(Array.from({ length: 100 }, () => getter(42)));
  • CacheConfigurationError: could not derive an auto-key from args — your args include something JSON.stringify can’t handle (circular ref, BigInt nested in an object). Switch to the options form with a custom key fn that picks JSON-safe fields.
  • Two calls with the same args hit the source function — driver default TTL is Infinity only if you haven’t set one. Check options.<driver>.ttl, or pass a TTL via the shorthand.
  • .invalidate() doesn’t seem to work — verify you’re calling it with the same args shape the wrapper was called with. The key function needs matching inputs to produce the same key.
  • Huge cache keys on the file driver — the auto-key JSON.stringify path grows unbounded. Use a custom key fn to project only what identifies the value.
  • Stampede Preventionremember(), the primitive under the hood
  • Set Options — the shape of the options object cached forwards to set()
  • Cache Tags — tag-based invalidation
  • ErrorsCacheConfigurationError and when it fires