cache
Caches get and find results based on params. On mutating methods (create, update, patch, remove), affected cache entries are automatically invalidated. Works as a before, after, or around hook.
Use the iff option to restrict caching to certain calls (e.g. internal ones) — invalidation always runs, for every call. For writes that skip the hooks (_patch & co.), wrap them in the returned hook's invalidate.
import { cache } from 'feathers-utils/hooks';Example
import { cache } from 'feathers-utils/hooks'
import { LRUCache } from 'lru-cache'
app.service('users').hooks({
around: {
all: [
cache({
map: new LRUCache({ max: 500 }),
transformParams: (params) => ({ query: params.query }),
}),
],
},
})Type declaration
Show Type Declarations
type Cache = {
get: (key: string) => Promisable<any>
set: (key: string, value: any) => Promisable<any>
delete: (key: string) => Promisable<any>
clear: () => any
keys: () => IterableIterator<string>
}
export type CacheEvent =
| {
type: "hit"
method: string
key: string
}
| {
type: "miss"
method: string
key: string
}
| {
type: "set"
method: string
key: string
}
| {
type: "invalidate"
method: string
key: string
}
| {
type: "clear"
method: string
}
| {
type: "skip"
method: string
}
export type CacheOptions<H extends HookContext = HookContext> = {
/**
* The cache implementation to use. It should implement the methods `get`,
* `set`, `delete`, `clear`, and `keys` - e.g. an `LRUCache` from `lru-cache`,
* a `Map`, or an adapter to Redis.
*
* Bound its size: the client chooses the `find` params (`$skip`, search
* terms, ...), so with a plain `Map` every distinct query stays in memory
* until the next write to the service.
*
* @example new LRUCache({ max: 500 })
*/
map: Cache
/**
* The id field of the items, read to invalidate cached `get`s. Only a
* fallback for services without `options.id`: a service that has one (every
* database adapter) uses it, since that is the field `get` is called with.
*
* @default 'id'
*/
id?: string
/**
* Params are stringified for the key-value cache, and there are params
* properties you don't want in the cache key. Use this function to transform
* the params before they are stringified.
*
* The {@link gateParams} util is built for exactly this: it declaratively
* selects/projects `params` keys (keeping `query` by default) so noise like
* `rateLimit` never ends up in the cache key.
*
* @example
* ```ts
*
*
* cache({
* map: new LRUCache({ max: 500 }),
* transformParams: (params) => gateParams(params, { rateLimit: false }),
* })
* ```
*/
transformParams: (params: Params) => Params
/**
* Custom serialization function for converting params into a cache key string.
* Uses {@link stringifyParams} by default, which sorts object keys and normalizes
* query operator arrays (`$or`, `$and`, `$in`, etc.) for order-independent caching.
*
* The default is crash-safe: it never throws on values that leak through
* `transformParams`. Circular references become `[Circular]`,
* functions/`undefined`/`symbol` are dropped, `BigInt` is stringified, and
* objects with `toJSON` (e.g. `Date`, `ObjectId`) are serialized via it.
*
* Override this to use a custom serialization strategy, e.g. to hash long keys
* for an external store — the id prefix stays separate, so invalidation keeps
* working.
*
* @example
* ```ts
*
*
*
* cache({
* map: redisCache,
* transformParams: (params) => ({ query: params.query }),
* serialize: (params) =>
* createHash('sha256').update(stringifyParams(params)).digest('base64url'),
* })
* ```
*/
serialize?: (params: Params) => string
/**
* Optional logger callback for cache events (hit, miss, set, invalidate,
* clear, skip). Useful for debugging and monitoring cache behavior. The
* events of the hook's `invalidate` function carry `method: 'invalidate'`.
*
* @example
* ```ts
* cache({
* map: new LRUCache({ max: 500 }),
* transformParams: (params) => ({ query: params.query }),
* logger: (event) => console.log(`cache ${event.type}`, event),
* })
* ```
*/
logger?: (event: CacheEvent) => void
/**
* How to clone results on store and on hit so callers can't mutate the shared
* cached object. The default is a `fast-copy` deep clone.
*
* Set to `false` to skip cloning entirely (fastest, but the caller MUST treat
* results as immutable), or pass a custom clone function (e.g. `structuredClone`).
*
* @default true
*/
clone?: boolean | (<T>(value: T) => T)
/**
* Only read from and write to the cache when this is truthy. Can be a boolean
* or a (possibly async) predicate that receives the `HookContext`.
*
* This gates the **caching of `get`/`find` only**. Invalidation on `create`,
* `update`, `patch` and `remove` always runs, so a call that is not allowed to
* be served from the cache can still never leave stale entries behind.
*
* A gated-out call logs a `skip` event (without a key — none is computed), so
* a cache that never fills is visible in the {@link CacheOptions.logger}
* instead of silent.
*
* A predicate runs on every hook run it gates — with a `before`/`after` or
* `around` registration that is twice per `get`/`find` call — so keep it cheap
* and side-effect free. A sync predicate is never awaited.
*
* @default true
*
* @example
* ```ts
*
*
*
* // only serve internal calls from the cache
* cache({
* map: new LRUCache({ max: 500 }),
* transformParams: (params) => ({ query: params.query }),
* iff: isProvider('server'),
* })
* ```
*/
iff?: boolean | PredicateFn<H>
}
/**
* The hook {@link cache} returns: usable as a `before`, `after` or `around`
* hook, with an `invalidate` function for the writes it does not see.
*/
export type CacheHook<H extends HookContext = HookContext> = {
(context: H, next?: NextFunction): Promise<void>
/**
* Invalidates the cache for a write the hook does not see - `_create`,
* `_update`, `_patch` or `_remove`: wrap the call, or pass its result. It
* deletes the cached `get`s of the written items and every cached `find`,
* and resolves to the result of the write.
*
* Without a write, or when its result shows no items with ids (`[]`, none at
* all - e.g. feathers-sequelize with `$returning: false`), the whole cache is
* cleared. A write that fails is rethrown and invalidates nothing.
*
* @example
* ```ts
* const todo = await todosCache.invalidate(todos._patch(1, { done: true }))
*
* // after a bulk write straight to the database
* await todosCache.invalidate()
* ```
*/
invalidate: <T = void>(write?: T) => Promise<Awaited<T>>
}
/**
* Caches `get` and `find` results based on `params`. On mutating methods (`create`, `update`,
* `patch`, `remove`), affected cache entries are automatically invalidated.
* Works as a `before`, `after`, or `around` hook.
*
* Use the `iff` option to restrict *caching* to certain calls (e.g. internal
* ones) — invalidation always runs, for every call. For writes that skip the
* hooks (`_patch` & co.), wrap them in the returned hook's `invalidate`.
*
* @example
* ```ts
*
*
*
* app.service('users').hooks({
* around: {
* all: [
* cache({
* map: new LRUCache({ max: 500 }),
* transformParams: (params) => ({ query: params.query }),
* }),
* ],
* },
* })
* ```
*
* @see https://utils.feathersjs.com/hooks/cache.html
*/
export declare const cache: <H extends HookContext = HookContext>(
options: CacheOptions<H>,
) => CacheHook<H>| Argument | Type | Description |
|---|---|---|
| options | CacheOptions<H> |
| type | methods | multi |
|---|---|---|
| before, after | find, get, create, update, patch, remove | yes |
The cache hook caches get and find results based on params. On mutating methods (create, update, patch, remove), affected cache entries are automatically invalidated.
- Cached
getentries are invalidated when the same id is updated, patched, or removed. - Cached
findentries are invalidated on any mutation, since any change could affect query results. createdoes not invalidate cachedgetentries (onlyfind).- The
iffoption restricts which calls are cached (e.g. internal ones only); invalidation always runs. - When a write does not show which items it changed (no result, items without ids, or none at all, e.g. feathers-sequelize with
$returning: false), the whole cache is cleared. - A
get/findthat was still reading while an invalidation ran is not stored: its result may predate that write.
Only writes through the hooks invalidate
The hook only sees the calls that run the service's hooks. These writes leave stale entries behind until they are evicted or their ttl runs out:
- the adapter methods without hooks:
_create,_update,_patchand_remove, - writes straight to the database, or from another process.
Give the cache a ttl that bounds how stale an entry may get, and invalidate it yourself after such a write.
Options
Caching Only Some Calls (iff)
iff gates the cache for get and find: when it is falsy for a call, the call is neither served from the cache nor stored in it. It takes a boolean or a (possibly async) predicate over the HookContext, so any predicate composes — e.g. isProvider, isContext, or a combination via and / or / not.
import { cache } from 'feathers-utils/hooks'
import { isProvider } from 'feathers-utils/predicates'
import { LRUCache } from 'lru-cache'
app.service('users').hooks({
around: {
all: [
cache({
map: new LRUCache({ max: 500 }),
transformParams: (params) => ({ query: params.query }),
// only internal calls are served from / written to the cache
iff: isProvider('server'),
}),
],
},
})Invalidation is not gated
iff deliberately applies to get/find only. create, update, patch and remove always invalidate the affected entries, no matter who made the call — an external patch still clears the entries an internal get put there. Gating invalidation too would let external writes leave stale data behind.
A gated-out call emits a skip event to the logger, so a cache that never fills shows up in your logs instead of failing silently. The event carries no key — computing one is exactly the work the gate skips.
The predicate is evaluated on every hook run it gates — with a before/after or around registration that is twice per get/find call (and therefore two skip events) — so keep it cheap and side-effect free.
Choosing Cache-Relevant Params (with gateParams)
Deciding which params keys form the cache key is the trickiest part of caching, and the two failure modes are asymmetric:
- False hits (dangerous): if a key that affects the result is left out (e.g.
user/tenant,provider), two semantically different requests collapse to the same key — one user can be served another user's cached data. - False misses (wasteful): if a per-request/metrics key is included (e.g.
rateLimit), every request produces a unique key and the cache never hits. A function-valued key (e.g.stashedfromstashable) would even make serialization throw.
The gateParams utility makes this explicit and safe. It takes a declarative path schema (true include, false drop, or a predicate/projection function). query is always included by default, and keys you never classified are kept by default — the safe direction, since a forgotten key causes at worst a harmless cache miss, never a false hit.
Transient keys that feathers-utils' own hooks attach to
params—rateLimit(rateLimit),skipHooks(skippable/addSkip), thestashedfunction and_stashableflag (stashable) — are never cache-relevant. Drop them withfalse, or keep only what you list viadropUnknownParams: true.
Exclude specific params (default)
Cache on everything except the keys you explicitly drop with false. This is the default direction — safe against false hits:
import { gateParams } from 'feathers-utils/utils'
cache({
map: new LRUCache({ max: 500 }),
transformParams: (params) =>
gateParams(params, { rateLimit: false, skipHooks: false }),
})Include only specific params
Set dropUnknownParams: true so only query (always) and the listed paths form the cache key. user.id is picked via dot-notation so different tenants never collide and per-request user fields don't bloat the key. Use onUnknownParams to log anything that was dropped:
import { gateParams } from 'feathers-utils/utils'
cache({
map: new LRUCache({ max: 500 }),
transformParams: (params) =>
gateParams(
params,
{ 'user.id': true }, // `query` is included automatically
{
dropUnknownParams: true,
onUnknownParams: (keys) =>
keys.forEach((key) => logger.warn('undeclared cache param', key)),
},
),
})Invalidating Manually
For the writes the hook does not see, the hook that cache() returns has an invalidate function. Wrap the write in it, or pass its result. It deletes the cached gets of the written items and every cached find, and resolves to the result of the write. Without a write, it clears the whole cache.
import { cache } from 'feathers-utils/hooks'
import { LRUCache } from 'lru-cache'
export const todosCache = cache({
map: new LRUCache({ max: 500 }),
transformParams: (params) => ({ query: params.query }),
})
app.service('todos').hooks({ around: { all: [todosCache] } })
// elsewhere, past the hooks of `todos`
const todo = await todosCache.invalidate(
app.service('todos')._patch(1, { done: true }),
)
// the changed items are unknown, e.g. after a bulk write straight to the database
await todosCache.invalidate()- The ids are read from the id field of the service the hook runs on.
- A result without items with ids clears the whole cache, as it does for a call through the hooks. That covers
[]and no result at all, e.g. feathers-sequelize with$returning: false. - A write that fails is rethrown and invalidates nothing.
- The
loggerevents ofinvalidatecarrymethod: 'invalidate'.
Cache Interface
Any object that implements the following interface can be used as a cache:
type Cache = {
get: (key: string) => Promisable<any>
set: (key: string, value: any) => Promisable<any>
delete: (key: string) => Promisable<any>
clear: () => any
keys: () => Generator<string, void, unknown>
}A plain Map satisfies this interface out of the box, as do many popular cache libraries.
Bound the cache
A Map never evicts. For external find calls the client chooses the params that form the key ($skip, search terms, $in lists, ...), so every distinct query stays in memory until the next write to the service. Use a cache with a size limit, like lru-cache below.
Examples of storages
LRU Cache (lru-cache)
Use lru-cache to limit the number of cached entries and automatically evict the least recently used ones.
import { cache } from 'feathers-utils/hooks'
import { LRUCache } from 'lru-cache'
const lruCache = new LRUCache({
max: 500, // Maximum number of entries
ttl: 1000 * 60 * 5, // Entries expire after 5 minutes
})
app.service('users').hooks({
around: {
all: [
cache({
map: lruCache,
transformParams: (params) => ({ query: params.query }),
}),
],
},
})The ttl bounds how stale an entry can get through writes the hook does not see, e.g. from another process or straight to the database. Serving an entry from the cache does not reset it.
Map
A Map is enough when the set of keys is small and fixed, e.g. for a few internal calls with known params, or in tests:
import { cache } from 'feathers-utils/hooks'
import { isProvider } from 'feathers-utils/predicates'
app.service('settings').hooks({
around: {
all: [
cache({
map: new Map(),
transformParams: (params) => ({ query: params.query }),
iff: isProvider('server'),
}),
],
},
})Redis
Redis (via ioredis) needs a thin adapter. The hook iterates keys() synchronously on every write, so the adapter tracks the keys it set, together with their expiry:
import { cache } from 'feathers-utils/hooks'
import Redis from 'ioredis'
const redis = new Redis()
const prefix = 'users-cache:'
const ttl = 60 * 5 // 5 minutes in seconds
// key -> expiry, so the keys Redis expired are dropped here too
const tracked = new Map<string, number>()
const redisCache = {
async get(key: string) {
const value = await redis.get(prefix + key)
return value ? JSON.parse(value) : undefined
},
async set(key: string, value: any) {
tracked.set(key, Date.now() + ttl * 1000)
await redis.set(prefix + key, JSON.stringify(value), 'EX', ttl)
},
async delete(key: string) {
tracked.delete(key)
await redis.del(prefix + key)
},
async clear() {
const keys = [...tracked.keys()]
tracked.clear()
if (keys.length) await redis.del(...keys.map((key) => prefix + key))
},
*keys() {
const now = Date.now()
for (const [key, expires] of tracked) {
if (expires <= now) tracked.delete(key)
else yield key
}
},
}
app.service('users').hooks({
around: {
all: [
cache({
map: redisCache,
transformParams: (params) => ({ query: params.query }),
// the JSON round trip already hands out a fresh copy
clone: false,
}),
],
},
})One process only
The keys are tracked per process. With several instances, a write on one does not invalidate the entries another one set: they stay until their ttl runs out. Keep the ttl short, or share the invalidation between the instances, e.g. via Redis pub/sub.
Custom Cache
You can build any custom cache as long as it implements the Cache interface. Here is an example of a simple time-based cache. It drops an expired entry when it is read, and on every write, when the hook iterates keys():
import { cache } from 'feathers-utils/hooks'
const ttl = 1000 * 60 * 5 // 5 minutes
const timedCache = () => {
const store = new Map<string, { value: any; expires: number }>()
return {
get(key: string) {
const entry = store.get(key)
if (!entry) return undefined
if (Date.now() > entry.expires) {
store.delete(key)
return undefined
}
return entry.value
},
set(key: string, value: any) {
store.set(key, { value, expires: Date.now() + ttl })
},
delete(key: string) {
store.delete(key)
},
clear() {
store.clear()
},
*keys() {
const now = Date.now()
for (const [key, entry] of store) {
if (now > entry.expires) store.delete(key)
else yield key
}
},
}
}
app.service('users').hooks({
around: {
all: [
cache({
map: timedCache(),
transformParams: (params) => ({ query: params.query }),
}),
],
},
})