@zudojs/cache
Keep copies of slow results so you can serve them again fast, with expiry, tags, locks and per-tenant scoping.
OVERVIEW
A cache is a small, fast store where you keep copies of results that were expensive to produce: a database row, an API response, a rendered page. The next time you need the same result, you read the copy instead of doing the slow work again.
@zudojs/cache gives you one object, a CacheService, with get, set, delete and a few helpers. Behind it sits an adapter, which is the actual storage. The package ships an in-memory adapter. To use Redis or another store, you implement the CacheAdapter interface and pass it in.
On top of plain storage it adds what real apps need: entries that expire on their own (TTL), tags that let you delete related entries together, locks so two workers do not run the same slow job at once, and namespaces that keep one tenant's data away from another's.
When you need it
- • The same slow read happens many times.
- • Many users see the same data (product lists, settings, flags).
- • You want to stop duplicate work under load.
- • Your app serves several tenants and each needs its own scope.
When you don't
- • The data changes on every request.
- • You must not lose the data. A cache can drop entries at any time; use a database.
- • You need one cache shared by several processes and have no adapter for a shared store yet. The memory adapter lives in a single process.
INSTALLATION
Install the package. It pulls in @zudojs/errors, @zudojs/types and @zudojs/serialization on its own. It needs Node 24 or newer.
These docs follow the framework source. If an export shown here is missing from the version you installed, update to the latest @zudojs release.
QUICK START
This creates a cache backed by memory, stores one value for 60 seconds, and reads it back. The { name: string } in angle brackets tells TypeScript what type the value has.
A read that finds the entry is a hit; one that does not is a miss. get never returns the value on its own. It returns an object, and you check hit before you trust value.
WATCH OUT
The key is user.123 with a dot, not user:123. The colon is reserved. The next section explains why.
KEYS AND NAMESPACES
A key is the name you store a value under. Before the key reaches the adapter, a key builder turns it into a full key of the form prefix:namespace:key. The default prefix is zudojs and the default separator is :, so set("user.123", ...) is stored as zudojs:user.123.
Every part is checked. A part may only contain letters, digits, ., _ and -, and it must not contain the separator. The full key may be at most 256 characters. A bad key throws a CacheError right away, with code ERR_INVALID_INPUT and statusCode 400. Since v1.2.0 that rejection is also counted in getStats().errors and emitted as a cache.error event, like an invalid TTL.
The separator is the only thing that marks where the namespace ends and your key begins. If your key could contain it, set("admin:x") would land inside the admin namespace. Rejecting the separator in each part closes that hole.
You can see what the key builder produces by using it directly.
Namespaces and tenant isolation
A namespace is a label that groups a set of keys. Two entries with the same key but different namespaces are different entries. A tenant is one customer or organisation in an app that serves many; giving each tenant its own namespace keeps their cached data apart.
You can set the namespace once for a whole service with config.namespace, or per call with { namespace }. Reads, writes, clear, tag invalidation and locks all stay inside the namespace they were given. An empty-string namespace is rejected in the config and per call, so a tenant id that resolved to "" never falls into the global keyspace. The error is a CacheError with code ERR_INVALID_INPUT and statusCode 400, the same one an invalid key throws.
This stores the same key under two tenants and then clears only one of them.
For one service per tenant, pass the namespace in the config and drop it from every call.
DANGER
A namespace that comes from a request (a tenant id, a header) is safe to pass in: the cache validates it and throws rather than widening the operation. The one thing it cannot do is guess the right tenant for you. Always pass the namespace on every call, or use one service per tenant.
Common mistake: building keys with a colon, such as `user:${id}`. Use a dot or a dash inside a key and let the namespace carry the scope.
TTL: HOW LONG AN ENTRY LIVES
TTL stands for time to live. It is the number of milliseconds an entry stays valid after you store it. When the time is up, the entry is treated as missing. The default is 5 minutes (DEFAULT_TTL_MS).
You set it once with config.defaultTtl and override it per call with { ttl }. Pass null for an entry that never expires. A TTL of 0, a negative number, or more than 24 hours (MAX_TTL_MS) throws.
Expiry is lazy: nothing runs on a timer. An expired entry is dropped the next time it is read or when the adapter makes room. The memory adapter also evicts the least recently used entries once it holds more than maxEntries (10,000) or roughly maxBytes (50 MB).
This stores two entries with different lifetimes, checks how long they have left, and extends one.
TIP
Timers use a monotonic clock, so a system clock jump does not stretch or shorten a TTL. set returns { success, key, expiresAt }; expiresAt is a Date for display only.
Common mistake: writing ttl: 0 to mean "keep forever". Zero is rejected. Use ttl: null.
GETORSET: READ, OR COMPUTE AND STORE
Most code that uses a cache does the same three steps: look in the cache, and on a miss do the slow work, store the result, and return it. getOrSet does those steps for you.
It also protects against a stampede. If 50 requests miss the same key at the same moment, only one of them runs your function; the other 49 wait for that result. Without this, all 50 would hit the database together.
This fetches a user twice. The database function runs once; the second call is served from the cache.
You should see hitting the database printed exactly once. getOrSet returns { value, cached }; value is always present, and cached tells you whether it came from the cache.
The third argument takes the same options as set (ttl, tags, namespace) plus forceRefresh, which skips the read and recomputes.
If you write the three steps by hand, check hit, not the value.
Common mistake: if (!cached) fetch.... The result object is never falsy, so that branch never runs, the cache never fills, and every call hits the database. Check cached.hit.
LOCKS: ONE AT A TIME
A lock is a named ticket that only one piece of code can hold at a time. While you hold it, anyone else who asks for the same name has to wait or give up. Use it around work that must not run twice at once, such as an import job or a counter update.
withLock(name, fn, options) takes the lock, runs fn, and releases the lock even if fn throws. The lock is a lease: it has a TTL (30 seconds by default) and is renewed while fn runs, so a slow job does not lose it. If the lease is lost anyway, the call throws instead of pretending it worked.
This runs a job under a lock and returns its result.
If someone already holds the lock, withLock retries 3 times, 100 ms apart, then throws a CacheError with code CACHE_LOCK_UNAVAILABLE. This shows that by asking for the same lock from inside itself.
Lock names go through the key builder, so they are validated like keys and scoped by namespace. withLock("import", fn, { namespace: "tenant-a" }) and the same call for tenant-b do not block each other.
WATCH OUT
The built-in lock store lives in one process. Two Node processes using it do not see each other's locks. For that you pass your own CacheLockStore (for example one backed by Redis) as config.lockStore. To share locks between several services in the same process, pass the exported defaultLockStore.
Common mistake: a lock name with a colon, like "job:1". It is rejected just as a key would be. Use "job.1".
SEEING WHAT THE CACHE DOES
The service counts hits, misses, sets, deletes and errors (including rejected input: an invalid key, namespace, pattern, tag or TTL), and it emits an event (a small message you can listen for) on every operation. Stats are on by default; turn them off with config.collectStats: false, after which getStats() returns null.
This listens for misses, does a few reads, and prints the counters.
Event names are cache.hit, cache.miss, cache.set, cache.delete, cache.clear and cache.error; subscribe to "*" for all of them. Event keys are full keys, prefix included. healthCheck() returns { healthy, adapter, latencyMs, checkedAt } and is a cheap probe for a readiness endpoint.
TIP
Set config.failSilently: true in production if a broken cache should look like a miss rather than crash a request. Key validation errors and lock failures still throw, because those are bugs, not outages.
API REFERENCE
Everything below is exported from @zudojs/cache. Most apps only need the first two functions and the CacheService methods.
Functions
| Name | What it does | Notes |
|---|---|---|
createCacheService({ adapter, config?, keyBuilder? }) | Builds the CacheService you call from app code. | adapter is required. |
createMemoryCacheAdapter(options?) | In-process adapter backed by a Map. | Options: maxEntries, maxBytes, defaultTtl, separator. |
createKeyBuilder({ prefix?, separator?, namespace? }) | Builds and validates full keys. | Pass as keyBuilder to the service, or use build() directly. |
createLockManager(options?) | Stand-alone lock manager with acquire() and withLock(). | The service has its own; use this only outside a service. |
createCacheStore, createTagStore, createInvalidationManager, createCacheMetrics | Factories for the internal pieces the service composes. | Only needed when you build your own service-like wrapper. |
isCacheError(value) | Type guard for CacheError. | Use in catch blocks. |
assertValidTag(tag) | Throws if a tag is empty, too long or malformed. | The service calls it for you. |
estimateValueBytes(value) | Approximate size the memory adapter charges for a value. | Handy for tuning maxBytes. |
stripUnsafeKeys(value) | Removes __proto__, constructor, prototype from a deserialized object. | Applied by JsonCacheSerializer. |
CacheService methods
| Name | What it does | Notes |
|---|---|---|
get<T>(key, { namespace? }) | Reads one entry. | Returns { hit, value, entry? }. |
set(key, value, { ttl?, tags?, namespace?, overwrite?, metadata? }) | Writes one entry. | Returns { success, key, expiresAt, skipped? }. overwrite: false skips existing keys. |
has(key, opts?) / delete(key, opts?) | Existence check / removal. | delete returns { deleted, key }. |
getOrSet<T>(key, fn, opts?) | Read, or compute and store. | Returns { value, cached }. Options add forceRefresh. |
ttl(key, opts?) / expire(key, ttl, opts?) | Remaining lifetime / set a new lifetime. | ttl returns whole ms (rounded down, since v1.2.0), null (never) or undefined (missing). |
clear({ namespace?, pattern? }) | Removes everything, or only matching entries. | Returns { cleared }. |
invalidateByTag(tags, { namespace? }) | Removes entries carrying any of the tags. | Scoped to the namespace. |
invalidateByPattern(pattern, { namespace? }) | Removes entries matching a glob. | * stops at :; ** spans. |
withLock<T>(name, fn, { ttl?, retryAttempts?, namespace? }) | Runs fn while holding a lock. | Throws CACHE_LOCK_UNAVAILABLE, CACHE_LOCK_LOST or CACHE_DISABLED. |
batch(operations, { namespace? }) | Runs a list of { type: "get" | "set" | "delete", key, value?, options? } in order. | One result per operation; a failure does not stop the rest. |
subscribe(eventType | "*", handler) | Listens for cache events. | Returns { unsubscribe }. |
getStats(), getLatencyStats(op), getLatencyHistogram(op), getHotKeys(n?), resetStats(), size() | Counters, percentiles, hot keys, live entry count. | Return null when collectStats is off (size() excepted). |
healthCheck(), connect(), disconnect() | Probe the adapter / lifecycle hooks. | The memory adapter needs neither connect nor disconnect. |
CacheConfig options
| Name | What it does | Notes |
|---|---|---|
enabled | Kill switch. When false, reads miss, writes no-op, withLock throws. | Default true. |
defaultTtl | TTL used when a call passes none. | Default 300_000 (5 min). null = never. |
namespace, prefix, separator | Key layout: prefix:namespace:key. | Defaults: none, "zudojs", ":". |
failSilently | Swallow adapter errors and return a neutral result. | Default false. Validation and lock errors still throw. |
collectStats | Track counters and latencies. | Default true. |
serializer | Copy values on the way in and out (JsonCacheSerializer, RawCacheSerializer, or your own). | Without one the memory adapter stores objects by reference. |
middlewares | Functions (ctx, next) => Promise wrapping every adapter call. | First entry is outermost. Always return next()'s result. |
lockStore | Where locks are kept. | Default: a fresh in-process store. Pass defaultLockStore to share. |
tagStore | Where tag→key mappings are kept. | Default: a fresh per-instance store. Replicas sharing one adapter must share one (any CacheTagStore). |
Classes and instances
| Name | What it does | Notes |
|---|---|---|
CacheService | The class behind createCacheService. | Construct with new CacheService({ adapter, config? }) if you prefer. |
MemoryCacheAdapter | The class behind createMemoryCacheAdapter. | LRU eviction by count and by estimated bytes. |
DefaultKeyBuilder, defaultKeyBuilder | Key builder class and a shared instance with defaults. | Has build, buildPattern, namespace(ns). |
JsonCacheSerializer, defaultSerializer | JSON serializer that keeps Date, Map, Set and BigInt. | new JsonCacheSerializer({ preserveTypes: false }) for plain JSON. |
RawCacheSerializer, rawSerializer | Pass-through serializer. | For adapters that serialize themselves. |
InMemoryLockStore, defaultLockStore, CacheLockManager | Lock storage and the manager that retries and renews. | Implement CacheLockStore for a distributed store. |
DefaultCacheStore, InMemoryTagStore, CacheInvalidationManager, InMemoryCacheMetrics | Internal building blocks. | Exposed for advanced composition and tests. |
Types
| Name | What it does | Notes |
|---|---|---|
CacheAdapter | Contract a storage backend implements. | name, get, set, delete, has, clear, plus optional keys, getMany, setMany, deleteMany, ttl, expire, size, connect, disconnect. |
CacheConfig | The config object above. | |
CacheGetResult<T>, CacheSetResult, CacheDeleteResult, CacheClearResult | Return shapes of the basic operations. | CacheGetResult.entry carries createdAt, expiresAt, tags, metadata on a hit. |
CacheOrComputeOptions, CacheOrComputeResult<T> | Options and result of getOrSet. | |
CacheBatchOperation, CacheBatchResult | Input and output of batch. | |
CacheTTL | number | null. | |
CacheStats, CacheHealth | Return shapes of getStats and healthCheck. | CacheHealth.disabled is true when enabled: false. |
CacheEvent, CacheEventType, CacheEventHandler, CacheEventSubscription | Event surface for subscribe. | Per-event types: CacheHitEvent, CacheMissEvent, CacheSetEvent, CacheDeleteEvent, CacheClearEvent, CacheErrorEvent. |
CacheMiddleware, CacheMiddlewareContext | Shape of a middleware and the { key, operation, startedAt } it receives. | |
CacheLockStore, CacheLock, CacheLockOptions | Contracts for custom lock backends. | |
CacheSerializer | { serialize(value), deserialize(value) }. | |
CacheKeyBuilder, CacheKeyOptions, CacheTagStore, CacheTagOptions, CacheMetrics, CacheStore | Contracts for the internal pieces. |
Errors
Every error thrown by this package is a CacheError from @zudojs/errors, re-exported here together with its factories.
| Name | What it does | Notes |
|---|---|---|
CacheError, isCacheError | Error class and type guard. | Has message, code, statusCode, operation, key. |
CacheErrorCode | Codes this package sets: CACHE_DISABLED, CACHE_OPERATION_FAILED, CACHE_INVALID_TTL, CACHE_MIDDLEWARE_RESULT_MISSING, CACHE_LOCK_UNAVAILABLE, CACHE_LOCK_ACQUIRE_FAILED, CACHE_LOCK_LOST. | Type only. An invalid key, namespace, pattern, tag or lock name throws with the shared code ERR_INVALID_INPUT (ErrorCode.INVALID_INPUT, status 400), which is not in this union. There is no CACHE_INVALID_KEY code. Before v1.2.0 an invalid tag surfaced as CACHE_OPERATION_FAILED. |
CacheOperation | Enum of operation names (GET, SET, DELETE, LOCK_ACQUIRE, ...). | Pass to getLatencyStats. |
cacheInvalidKeyError, cacheSerializationError, cacheDeserializationError, cacheConnectionError, cacheTimeoutError, cacheAdapterNotConfiguredError | Factories for building a CacheError of each kind. | Useful inside a custom adapter. |
Constants
| Name | What it does | Notes |
|---|---|---|
DEFAULT_TTL_MS, MIN_TTL_MS, MAX_TTL_MS | 5 minutes, 1 ms, 24 hours. | TTL range accepted by set. |
DEFAULT_PREFIX, DEFAULT_SEPARATOR, MAX_KEY_LENGTH | "zudojs", ":", 256. | |
CACHE_KEY_PATTERN, CACHE_PATTERN_PART_PATTERN, MAX_TAG_LENGTH | Regexes for key parts and glob parts; tag limit (128). | |
DEFAULT_LOCK_TTL_MS, DEFAULT_LOCK_RETRY_ATTEMPTS, DEFAULT_LOCK_RETRY_DELAY_MS | 30 s, 3, 100 ms. | |
DEFAULT_MAX_ENTRIES, DEFAULT_MAX_MEMORY_BYTES, EXPIRED_PURGE_INTERVAL_MS | 10,000 entries, 50 MB, 30 s purge interval. | Memory adapter defaults. |
MAX_LATENCY_SAMPLES, MAX_TRACKED_KEYS, LATENCY_BUCKETS | 1,000 samples, 1,024 hot keys, histogram edges in ms. | Metrics limits. |
COMMON MISTAKES
-
Treating
get()'s result as the value.if (!result)is never true, so the miss branch never runs and the cache never fills. Checkresult.hitand readresult.value. -
Colons in keys, tags or lock names.
set("user:1", ...)throws aCacheErrorbecause:is the separator. Writeuser.1and put the scope in the namespace. -
Tagging in one namespace and invalidating in another. The call returns
{ cleared: 0 }and stale data stays. Pass the samenamespacetosetandinvalidateByTag, or configure it once on the service. -
Using
"*"to mean "everything".*stops at the separator, so namespaced keys survive. Useclear()for all,clear({ namespace })per tenant, or"**"when you really want to cross namespaces. -
ttl: 0for "never expire". Zero is rejected withCACHE_INVALID_TTL. Usettl: null. -
Expecting the memory adapter to be shared. Each process, and each
createMemoryCacheAdapter()call, has its own store and its own locks. For several servers you need an adapter and alockStorebacked by a shared system such as Redis.
COMPLETE EXPORT INDEX
Every name @zudojs/cache exports from its package root at v1.1.1 — 104 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.
Show all 104 exports
CacheError CacheInvalidationManager CacheLockManager CacheService DefaultCacheStore DefaultKeyBuilder InMemoryCacheMetrics InMemoryLockStore InMemoryTagStore JsonCacheSerializer MemoryCacheAdapter RawCacheSerializerassertValidTag cacheAdapterNotConfiguredError cacheConnectionError cacheDeserializationError cacheInvalidKeyError cacheSerializationError cacheTimeoutError createCacheMetrics createCacheService createCacheStore createInvalidationManager createKeyBuilder createLockManager createMemoryCacheAdapter createTagStore estimateValueBytes isCacheError stripUnsafeKeysBaseCacheEvent CacheAdapter CacheBatchOperation CacheBatchResult CacheClearEvent CacheClearOptions CacheClearResult CacheConfig CacheDeleteEvent CacheDeleteManyResult CacheDeleteResult CacheEntry CacheErrorEvent CacheErrorOptions CacheEventSubscription CacheGetResult CacheHealth CacheHealthChecker CacheHitEvent CacheKeyBuilder CacheKeyOptions CacheKeysOptions CacheLock CacheLockOptions CacheLockStore CacheMetrics CacheMiddlewareContext CacheMissEvent CacheOrComputeOptions CacheOrComputeResult CacheSerializationOptions CacheSerializer CacheSetEvent CacheSetManyOptions CacheSetOptions CacheSetResult CacheStats CacheStore CacheTagOptions CacheTagStoreCacheErrorCode CacheEvent CacheEventHandler CacheEventType CacheExpiration CacheKey CacheMiddleware CacheNamespace CacheTag CacheTTL MaybePromiseCACHE_KEY_PATTERN CACHE_PATTERN_PART_PATTERN DEFAULT_LOCK_RETRY_ATTEMPTS DEFAULT_LOCK_RETRY_DELAY_MS DEFAULT_LOCK_TTL_MS DEFAULT_MAX_ENTRIES DEFAULT_MAX_MEMORY_BYTES DEFAULT_PREFIX DEFAULT_SEPARATOR DEFAULT_TTL_MS defaultKeyBuilder defaultLockStore defaultSerializer EXPIRED_PURGE_INTERVAL_MS LATENCY_BUCKETS MAX_KEY_LENGTH MAX_LATENCY_SAMPLES MAX_TAG_LENGTH MAX_TRACKED_KEYS MAX_TTL_MS MIN_TTL_MS rawSerializerCacheOperation