High Performance Layer 1 / Layer 2 Caching with Keyv Storage
cacheable is a high performance layer 1 / layer 2 caching engine that is focused on distributed caching with enterprise features such as CacheSync. It is built on top of the robust storage engine Keyv and provides a simple API to cache and retrieve data.
CacheableMemory(1m = 60000) (1h = 3600000) (1d = 86400000)cacheable is primarily used as an extension to your caching engine with a robust storage backend Keyv, Memoization (Wrap), Hooks, Events, and Statistics.
npm install cacheable
import { Cacheable } from 'cacheable'; const cacheable = new Cacheable(); await cacheable.set('key', 'value', 1000); const value = await cacheable.get('key');
This is a basic example where you are only using the in-memory storage engine. To enable layer 1 and layer 2 caching you can use the secondary property in the options:
import { Cacheable } from 'cacheable'; import KeyvRedis from '@keyv/redis'; const secondary = new KeyvRedis('redis://user:pass@localhost:6379'); const cache = new Cacheable({secondary});
In this example, the primary store we will use lru-cache and the secondary store is Redis. You can also set multiple stores in the options:
import { Cacheable } from 'cacheable'; import { Keyv } from 'keyv'; import KeyvRedis from '@keyv/redis'; import { LRUCache } from 'lru-cache' const primary = new Keyv({store: new LRUCache()}); const secondary = new KeyvRedis('redis://user:pass@localhost:6379'); const cache = new Cacheable({primary, secondary});
This is a more advanced example and not needed for most use cases.
The following hooks are available for you to extend the functionality of cacheable via CacheableHooks enum:
BEFORE_SET: This is called before the set() method is called.AFTER_SET: This is called after the set() method is called.BEFORE_SET_MANY: This is called before the setMany() method is called.AFTER_SET_MANY: This is called after the setMany() method is called.BEFORE_GET: This is called before the get() method is called.AFTER_GET: This is called after the get() method is called.BEFORE_GET_MANY: This is called before the getMany() method is called.AFTER_GET_MANY: This is called after the getMany() method is called.BEFORE_SECONDARY_SETS_PRIMARY: This is called when the secondary store sets the value in the primary store.An example of how to use these hooks:
import { Cacheable, CacheableHooks } from 'cacheable'; const cacheable = new Cacheable(); cacheable.onHook(CacheableHooks.BEFORE_SET, (data) => { console.log(`before set: ${data.key} ${data.value}`); });
A BEFORE_SET hook can change an entry's time-to-live by reassigning data.ttl. This works for set(), getOrSet(), and wrap() because they all run through BEFORE_SET. You can assign a single value (applied to every store) or a per-store object so the primary and secondary stores expire at different rates:
import { Cacheable, CacheableHooks } from 'cacheable'; import KeyvRedis from '@keyv/redis'; const secondary = new KeyvRedis('redis://user:pass@localhost:6379'); const cache = new Cacheable({ secondary }); cache.onHook(CacheableHooks.BEFORE_SET, (data) => { // Keep this entry short-lived in memory (primary) but longer in Redis (secondary) data.ttl = { primary: '10s', secondary: '5m' }; });
Each store resolves its TTL independently, taking the first defined value down this list and then capping it with maxTtl. This is the single source of truth for how every TTL (operation, hook, or default) is resolved:
| Precedence (highest first) | Primary store | Secondary store |
|---|---|---|
1. BEFORE_SET hook | data.ttl (scalar, or .primary) | data.ttl (scalar, or .secondary) |
2. Operation ttl | ttl (scalar, or .primary) | ttl (scalar, or .secondary) |
| 3. Store default | new Keyv({ ttl }) on the primary | new Keyv({ ttl }) on the secondary |
4. Instance ttl | new Cacheable({ ttl }) | new Cacheable({ ttl }) |
| Cap (applied last) | maxTtl | maxTtl |
What happens when you override:
{ primary, secondary }): each field applies to that store independently. A field you leave out falls back to that store's normal resolution — including any per-store ttl you passed to the operation.secondary you passed to the operation. Use the object form if you want the stores to differ.data.ttl already holds. Leaving data.ttl untouched keeps each store on its normal resolution (the default behavior).0, null, or undefined clears the TTL → the entry never expires, ignoring the cascade (store default and instance ttl). This matches the “disable the ttl” behavior in Shorthand for Time to Live; take care when assigning a possibly-nullable value to data.ttl.'10 seconds') throws; set catches it, emits the error event, and returns false without caching — so a hook typo silently disables caching for that write. Listen on error to catch it.maxTtl is re-applied to each store after the hook, so a hook can never push an entry past the cap.ttl: 0) so tag invalidation can always reach the longest-lived copy.AFTER_SET runs (and for sync replication), data.ttl has been normalized to the effective primary TTL as a number; the secondary store's effective TTL is not exposed on the item. AFTER_SET_MANY differs: setMany does not mutate the items you passed, so a handler there still sees each item.ttl exactly as set (a per-store object stays an object).TypeScript:
onHook(CacheableHooks.BEFORE_SET, (data) => …)typesdataautomatically — no annotation needed, and an invaliddata.ttlshape is a compile error. Payload types are exported too (CacheableHookItem,CacheableSetItem).
Note on backfills: like a per-store
ttlpassed to an operation, a primary TTL set by a hook governs only that write — it is not persisted per key. Once the primary copy expires, the next read repopulates the primary from the secondary using the usual TTL propagation rules. Set a primary store default TTL (new Keyv({ ttl })) or an instancemaxTtlif you need the primary to stay short across backfills.
Note on sync: a per-store TTL writes the secondary‘s TTL to the (typically shared) secondary store directly; CacheableSync replicates only the primary TTL to other instances’ in-memory primaries. For the usual shared-secondary topology this is correct — the secondary is written once and read by every instance.
The same per-store object is accepted by setMany items and the BEFORE_SET_MANY hook:
cache.onHook(CacheableHooks.BEFORE_SET_MANY, (items) => { for (const item of items) { item.ttl = { primary: '10s', secondary: '5m' }; } }); await cache.setMany([{ key: 'a', value: 1, ttl: { primary: '10s', secondary: '5m' } }]);
Here is an example of how to use BEFORE_SECONDARY_SETS_PRIMARY hook:
import { Cacheable, CacheableHooks } from 'cacheable'; import KeyvRedis from '@keyv/redis'; const secondary = new KeyvRedis('redis://user:pass@localhost:6379'); const cache = new Cacheable({secondary}); cache.onHook(CacheableHooks.BEFORE_SECONDARY_SETS_PRIMARY, (data) => { console.log(`before secondary sets primary: ${data.key} ${data.value} ${data.ttl}`); });
This is called when the secondary store sets the value in the primary store. This is useful if you want to do something before the value is set in the primary store such as manipulating the ttl or the value. Because this hook only writes the primary store, its ttl is a single value (a number or shorthand string), not a per-store object.
The following events are provided:
error: Emitted when an error occurs.cache:hit: Emitted when a cache hit occurs.cache:miss: Emitted when a cache miss occurs.Here is an example of using the error event:
import { Cacheable, CacheableEvents } from 'cacheable'; const cacheable = new Cacheable(); cacheable.on(CacheableEvents.ERROR, (error) => { console.error(`Cacheable error: ${error.message}`); });
We also offer cache:hit and cache:miss events. These events are emitted when a cache hit or miss occurs, respectively. Here is how to use them:
import { Cacheable, CacheableEvents } from 'cacheable'; const cacheable = new Cacheable(); cacheable.on(CacheableEvents.CACHE_HIT, (data) => { console.log(`Cache hit: ${data.key} ${data.value} ${data.store}`); // the store will say primary or secondary }); cacheable.on(CacheableEvents.CACHE_MISS, (data) => { console.log(`Cache miss: ${data.key} ${data.store}`); // the store will say primary or secondary });
cacheable is built as a layer 1 and layer 2 caching engine by default. The purpose is to have your layer 1 be fast and your layer 2 be more persistent. The primary store is the layer 1 cache and the secondary store is the layer 2 cache. By adding the secondary store you are enabling layer 2 caching. By default the operations are blocking but fault tolerant:
Setting Data: Sets the value in the primary store and then the secondary store.Getting Data: Gets the value from the primary if the value does not exist it will get it from the secondary store and set it in the primary store.Deleting Data: Deletes the value from the primary store and secondary store at the same time waiting for both to respond.Clearing Data: Clears the primary store and secondary store at the same time waiting for both to respond.When Getting Data if the value does not exist in the primary store it will try to get it from the secondary store. If the secondary store returns the value it will set it in the primary store. Because we use TTL Propagation the value will be set in the primary store with the TTL of the secondary store unless the time to live (TTL) is greater than the primary store which will then use the TTL of the primary store. An example of this is:
import { Cacheable } from 'cacheable'; import {Keyv} from 'keyv'; import KeyvRedis from '@keyv/redis'; const secondary = new Keyv({ store: new KeyvRedis('redis://user:pass@localhost:6379'), ttl: 1000 }); const cache = new Cacheable({secondary, ttl: 100}); await cache.set('key', 'value'); // sets the value in the primary store with a ttl of 100 ms and secondary store with a ttl of 1000 ms await sleep(500); // wait for .5 seconds const value = await cache.get('key'); // gets the value from the secondary store and now sets the value in the primary store with a ttl of 500 ms which is what is left from the secondary store
In this example the primary store has a ttl of 100 ms and the secondary store has a ttl of 1000 ms. Because the ttl is greater in the secondary store it will default to setting ttl value in the primary store.
import { Cacheable } from 'cacheable'; import {Keyv} from 'keyv'; import KeyvRedis from '@keyv/redis'; const primary = new Keyv({ ttl: 200 }); const secondary = new Keyv({ store: new KeyvRedis('redis://user:pass@localhost:6379'), ttl: 1000 }); const cache = new Cacheable({primary, secondary}); await cache.set('key', 'value'); // sets the value in the primary store with a ttl of 200 ms and secondary store with a ttl of 1000 ms await sleep(200); // wait for .2 seconds const value = await cache.get('key'); // gets the value from the secondary store and now sets the value in the primary store with a ttl of 200 ms which is what the primary store is set with
Cacheable TTL propagation is a feature that allows you to set a time to live (TTL) for the cache. By default the TTL is set in the following order:
ttl = set at the function ?? storage adapter ttl ?? cacheable ttl
This means that if you set a TTL at the function level it will override the storage adapter TTL and the cacheable TTL. If you do not set a TTL at the function level it will use the storage adapter TTL and then the cacheable TTL. If you do not set a TTL at all it will use the default TTL of undefined which is disabled.
When you override the ttl on an operation it normally applies to both the primary and secondary stores. If you want each store to expire at a different rate for that specific key, pass a per-store object as the ttl with primary and/or secondary fields. Each field accepts a number in milliseconds or a shorthand string.
import { Cacheable } from 'cacheable'; import { Keyv } from 'keyv'; import KeyvRedis from '@keyv/redis'; const secondary = new KeyvRedis('redis://user:pass@localhost:6379'); const cache = new Cacheable({ secondary }); // Keep this key 10 seconds in memory (primary) but 5 minutes in Redis (secondary) await cache.set('key', 'value', { ttl: { primary: '10s', secondary: '5m' } });
Any field you leave out falls back to that store's own default TTL resolution (storage adapter TTL, then the cacheable instance TTL). For example, only overriding the primary store leaves the secondary on its default:
const secondary = new Keyv({ store: new KeyvRedis('redis://user:pass@localhost:6379'), ttl: '1h' }); const cache = new Cacheable({ secondary }); // Primary expires in 10s; secondary keeps its own 1 hour default await cache.set('key', 'value', { ttl: { primary: '10s' } });
The per-store object works anywhere a ttl is accepted, including getOrSet and wrap:
await cache.getOrSet('key', async () => 'value', { ttl: { primary: '10s', secondary: '5m' } }); const getUser = cache.wrap(fetchUser, { ttl: { primary: '1m', secondary: '1d' } });
Passing a plain number or shorthand string (such as '1h') still applies the same TTL to every store, and the maxTtl cap is applied to each store independently. The same per-store object is accepted by setMany items, and a BEFORE_SET hook can override these per-store TTLs and takes precedence over the value passed to the operation.
Note on backfills: A per-store TTL (whether passed to the operation or set by a
BEFORE_SEThook) governs the write of that operation. The primary TTL is not persisted per key, so once a primary (layer 1) entry expires, the next read is served from the secondary and repopulates the primary using the secondary's remaining lifetime — following the usual TTL propagation rules — rather than re-applying the original primary TTL. If you need the primary to keep a consistently shorter lifetime across backfills, set a primary store default TTL (new Keyv({ ttl })) or an instancemaxTtl, which both bound the repopulated TTL.
By default Cacheable and CacheableMemory the ttl is in milliseconds but you can use shorthand for the time to live. Here are the following shorthand values:
ms: Milliseconds such as (1ms = 1)s: Seconds such as (1s = 1000)m: Minutes such as (1m = 60000)h or hr: Hours such as (1h = 3600000)d: Days such as (1d = 86400000)Here is an example of how to use the shorthand for the ttl:
import { Cacheable } from 'cacheable'; const cache = new Cacheable({ ttl: '15m' }); //sets the default ttl to 15 minutes (900000 ms) cache.set('key', 'value', '1h'); //sets the ttl to 1 hour (3600000 ms) and overrides the default
if you want to disable the ttl you can set it to 0 or undefined:
import { Cacheable } from 'cacheable'; const cache = new Cacheable({ ttl: 0 }); //sets the default ttl to 0 which is disabled cache.set('key', 'value', 0); //sets the ttl to 0 which is disabled
If you set the ttl to anything below 0 or undefined it will disable the ttl for the cache and the value that returns will be undefined. With no ttl set the value will be stored indefinitely.
import { Cacheable } from 'cacheable'; const cache = new Cacheable({ ttl: 0 }); //sets the default ttl to 0 which is disabled console.log(cache.ttl); // undefined cache.ttl = '1h'; // sets the default ttl to 1 hour (3600000 ms) console.log(cache.ttl); // '1h' cache.ttl = -1; // sets the default ttl to 0 which is disabled console.log(cache.ttl); // undefined
The get and getMany methods support a raw option, which returns the full stored metadata (StoredDataRaw<T>) instead of just the value:
import { Cacheable } from 'cacheable'; const cache = new Cacheable(); // store a value await cache.set('user:1', { name: 'Alice' }); // default: only the value const user = await cache.get<{ name: string }>('user:1'); console.log(user); // { name: 'Alice' } // with raw: full record including expiration const raw = await cache.get<{ name: string }>('user:1', { raw: true }); console.log(raw.value); // { name: 'Alice' } console.log(raw.expires); // e.g. 1677628495000 or null
// getMany with raw option await cache.set('a', 1); await cache.set('b', 2); const raws = await cache.getMany<number>(['a', 'b'], { raw: true }); raws.forEach((entry, idx) => { console.log(`key=${['a','b'][idx]}, value=${entry?.value}, expires=${entry?.expires}`); });
The hasMany method allows you to efficiently check if multiple keys exist in the cache. It leverages Keyv's native hasMany support for optimal performance:
import { Cacheable } from 'cacheable'; const cache = new Cacheable(); // set some values await cache.set('user:1', { name: 'Alice' }); await cache.set('user:2', { name: 'Bob' }); // check if multiple keys exist const exists = await cache.hasMany(['user:1', 'user:2', 'user:3']); console.log(exists); // [true, true, false]
The hasMany method returns an array of booleans in the same order as the input keys. This is particularly useful when you need to verify the existence of multiple cache entries before performing batch operations.
The Cacheable class exposes both primary and secondary as Keyv instances. Keyv provides an iterator() async generator for walking every entry in a store, but it is only available on stores that support iteration. When a store does not support it, keyv.iterator is undefined, so you must feature-check it before calling.
Keyv enables iterator() for:
new Keyv() whose store is a Map (the Keyv default), and@keyv/redis, @keyv/valkey, @keyv/mongo, @keyv/sqlite, @keyv/postgres, @keyv/mysql, and @keyv/etcd.Heads up: Cacheable's default primary store is the high-performance in-memory store from
@cacheable/memory(created withcreateKeyv()). It is neither a rawMapnor one of the recognized adapters, socache.primary.iteratorisundefined. See Iterating the default in-memory primary for how to walk it.
Important Notes:
iterator exists before using it — it is undefined on stores that don't support iteration.@keyv/redis uses SCAN under the hood). Avoid it on hot paths.A secondary store backed by a recognized adapter (Redis, Valkey, Mongo, SQLite, Postgres, MySQL, etcd) supports iterator() directly. Each iteration yields a [key, value] tuple:
import { Cacheable } from 'cacheable'; import KeyvRedis from '@keyv/redis'; const secondary = new KeyvRedis('redis://user:pass@localhost:6379'); const cache = new Cacheable({ secondary }); await cache.set('user:1', { name: 'Alice', role: 'admin' }); await cache.set('user:2', { name: 'Bob', role: 'user' }); if (cache.secondary?.iterator) { for await (const [key, value] of cache.secondary.iterator()) { console.log(`${key}:`, value); } }
If you need a primary store you can walk with iterator(), use a Map-backed new Keyv() (or any recognized adapter) as the primary. A plain Keyv is iterable because its default store is a Map:
import { Cacheable } from 'cacheable'; import { Keyv } from 'keyv'; const cache = new Cacheable({ primary: new Keyv() }); await cache.set('user:1', { name: 'Alice' }); await cache.set('user:2', { name: 'Bob' }); if (cache.primary.iterator) { for await (const [key, value] of cache.primary.iterator()) { console.log(`${key}:`, value); } }
The default primary from @cacheable/memory does not provide a Keyv iterator(). To walk it, reach the underlying CacheableMemory store through cache.primary.store and use its items iterator. Each item is a { key, value, expires } record (expired entries are skipped automatically), where value holds Keyv's stored envelope — the decoded value is at item.value.value:
import { Cacheable, KeyvCacheableMemory } from 'cacheable'; const cache = new Cacheable(); await cache.set('user:1', { name: 'Alice' }); await cache.set('user:2', { name: 'Bob' }); // cache.primary.store is the KeyvCacheableMemory adapter; its .store is the CacheableMemory const memory = (cache.primary.store as KeyvCacheableMemory).store; for (const item of memory.items) { // item.value is Keyv's { value, expires } envelope console.log(`${item.key}:`, item.value.value); }
If you'd rather not depend on the envelope shape, iterate the keys and read each entry back through the cache:
const memory = (cache.primary.store as KeyvCacheableMemory).store; for (const { key } of memory.items) { console.log(`${key}:`, await cache.get(key)); }
Here's a recommended helper function for safe iteration that checks for store availability and iterator support:
import { Cacheable } from 'cacheable'; import type { Keyv } from 'keyv'; async function iterateStore(store: Keyv | undefined, storeName: string) { if (!store) { console.log(`${storeName} store not configured`); return; } if (!store.iterator) { console.log(`${storeName} store does not support iteration`); return; } console.log(`${storeName} store entries:`); for await (const [key, value] of store.iterator()) { console.log(` ${key}:`, value); } } // Usage const cache = new Cacheable({ /* options */ }); await iterateStore(cache.primary, 'Primary'); await iterateStore(cache.secondary, 'Secondary');
Note that with the default configuration the Primary branch above will report that it “does not support iteration” — that is expected, because the default @cacheable/memory primary has no iterator(). Use the CacheableMemory.items approach for it.
Keyv assigns an iterator() method only when the store is a raw Map or one of the adapters it recognizes as iterable. For every other store, keyv.iterator is undefined.
Stores that support iterator():
new Keyv() (its store is a Map)@keyv/redis and @keyv/valkey@keyv/mongo, @keyv/sqlite, @keyv/postgres, @keyv/mysql, and @keyv/etcdStores that do not support iterator():
@cacheable/memory primary (createKeyv()) — walk it through its CacheableMemory.items accessor insteadIf you want your layer 2 (secondary) store to be non-blocking you can set the nonBlocking property to true in the options. This will make the secondary store non-blocking and will not wait for the secondary store to respond on setting data, deleting data, or clearing data. This is useful if you want to have a faster response time and not wait for the secondary store to respond. Here is a full list of what each method does in nonBlocking mode:
set - in non-blocking mode it will set at the primary storage and then in the background update secondary
get - in non-blocking mode it will only check the primary storage but then in the background look to see if there is a value in the secondary and update the primary
getMany - in non-blocking mode it will only check the primary storage but then in the background look to see if there is a value in the secondary and update the primary
getRaw - in non-blocking mode it will only check the primary storage but then in the background look to see if there is a value in the secondary and update the primary
getManyRaw - in non-blocking mode it will only check the primary storage but then in the background look to see if there is a value in the secondary and update the primary
@keyv/redis is one of the most popular storage adapters used with cacheable. It provides a Redis-backed cache store that can be used as a secondary store. It is a bit complicated to setup as by default it causes hangs and blocking with its default configuration. To get past this you will need to configure the following:
Construct your own Redis client via the createClient() method from @keyv/redis with the following options:
disableOfflineQueue to truesocket.reconnectStrategy to false In the KeyvRedis options:throwOnConnectError to false In the Cacheable options:nonBlocking to trueWe have also build a function to help with this called createKeyvNonBlocking inside the @keyv/redis package after version 4.6.0. Here is an example of how to use it:
import { Cacheable } from 'cacheable'; import { createKeyvNonBlocking } from '@keyv/redis'; const secondary = createKeyvNonBlocking('redis://user:pass@localhost:6379'); const cache = new Cacheable({ secondary, nonBlocking: true });
The getOrSet method provides a convenient way to implement the cache-aside pattern. It attempts to retrieve a value from cache, and if not found, calls the provided function to compute the value and store it in cache before returning it.
import { Cacheable } from 'cacheable'; // Create a new Cacheable instance const cache = new Cacheable(); // Use getOrSet to fetch user data async function getUserData(userId: string) { return await cache.getOrSet( `user:${userId}`, async () => { // This function only runs if the data isn't in the cache console.log('Fetching user from database...'); // Simulate database fetch return { id: userId, name: 'John Doe', email: 'john@example.com' }; }, { ttl: '30m' } // Cache for 30 minutes ); } // First call - will fetch from "database" const user1 = await getUserData('123'); console.log(user1); // { id: '123', name: 'John Doe', email: 'john@example.com' } // Second call - will retrieve from cache const user2 = await getUserData('123'); console.log(user2); // Same data, but retrieved from cache
import { Cacheable } from 'cacheable'; import {KeyvRedis} from '@keyv/redis'; const secondary = new KeyvRedis('redis://user:pass@localhost:6379'); const cache = new Cacheable({secondary, nonBlocking: true});
cacheable includes CacheableSync, a feature that enables distributed cache synchronization across multiple instances using Pub/Sub messaging via Qified. When a value is set or deleted in one cache instance, all other connected instances automatically receive and apply the update.
CacheableSync uses message providers from Qified to broadcast cache operations (SET and DELETE) to all connected cache instances. Each instance subscribes to these events and automatically updates its primary (example: in-memory) storage when receiving updates from other instances.
Qified supports multiple providers and you can learn more by going to https://qified.org.
import { Cacheable } from 'cacheable'; import { RedisMessageProvider } from '@qified/redis'; // Create a Redis message provider const provider = new RedisMessageProvider({ connection: { host: 'localhost', port: 6379 } }); // Create cache instances with sync enabled const cache1 = new Cacheable({ sync: { qified: provider } }); const cache2 = new Cacheable({ sync: { qified: provider } }); // Set a value in cache1 await cache1.set('key', 'value'); // Note: you might want to sleep for a bit based on the backend. // The value is automatically synced to cache2 const value = await cache2.get('key'); // Returns 'value'
You can use multiple message providers for redundancy:
import { Cacheable } from 'cacheable'; import { RedisMessageProvider } from '@qified/redis'; import { NatsMessageProvider } from '@qified/nats'; const redisProvider = new RedisMessageProvider({ connection: { host: 'localhost', port: 6379 } }); const natsProvider = new NatsMessageProvider({ servers: ['nats://localhost:4222'] }); const cache = new Cacheable({ sync: { qified: [redisProvider, natsProvider] } });
You can also pass a pre-configured Qified instance:
import { Cacheable } from 'cacheable'; import { Qified } from 'qified'; import { RedisMessageProvider } from '@qified/redis'; const provider = new RedisMessageProvider({ connection: { host: 'localhost', port: 6379 } }); const qified = new Qified({ messageProviders: [provider] }); const cache = new Cacheable({ sync: { qified } });
When multiple services share the same Redis instance (or other message provider), you can use namespaces to isolate cache synchronization events between services. This prevents one service‘s cache updates from affecting another service’s cache.
import { Cacheable } from 'cacheable'; import { RedisMessageProvider } from '@qified/redis'; const provider = new RedisMessageProvider({ connection: { host: 'localhost', port: 6379 } }); // Service 1 with namespace const serviceA = new Cacheable({ namespace: 'service-a', sync: { qified: provider } }); // Service 2 with different namespace const serviceB = new Cacheable({ namespace: 'service-b', sync: { qified: provider } }); // Set value in service A await serviceA.set('config', { timeout: 5000 }); // Service B won't receive this update because it has a different namespace const value = await serviceB.get('config'); // undefined
How Namespace Isolation Works:
cache:set and cache:deleteservice-a::cache:set, service-b::cache:setNote: The namespace is automatically passed from Cacheable to CacheableSync, so you only need to set it once in the Cacheable options.
SET Operations: When you call cache.set() or cache.setMany(), the cache:
cache:set event with the key, value, ttl, and cacheIdprimary storage (excluding the originating instance)DELETE Operations: When you call cache.delete() or cache.deleteMany(), the cache:
cache:delete event with the key and cacheIdcacheId to properly filter sync events. This is setup by default but you can set it if you want.cacheId. Events are only applied if they come from a different instance, preventing infinite loops.You can set a maxTtl option to enforce an upper bound on any TTL in the cache. When maxTtl is set:
maxTtl will be capped to maxTtl.maxTtl.maxTtl limit.maxTtl is enforced on both primary and secondary stores.BEFORE_SET hook is also capped, per store.This is useful when you want to guarantee that no cache entry lives longer than a certain duration, regardless of what TTL is passed to individual set() calls or set by a hook.
import { Cacheable } from 'cacheable'; // No entry can live longer than 1 hour const cache = new Cacheable({ maxTtl: '1h' }); await cache.set('key1', 'value1', '2h'); // capped to 1 hour await cache.set('key2', 'value2'); // also capped to 1 hour (would otherwise be indefinite) await cache.set('key3', 'value3', '30m'); // 30 minutes is within maxTtl, so it stays as-is
You can also set maxTtl after construction:
const cache = new Cacheable(); cache.maxTtl = 5000; // 5 seconds max cache.maxTtl = '10m'; // 10 minutes max cache.maxTtl = undefined; // disable maxTtl (no upper bound)
You can associate cache entries with tags and later invalidate every entry that shares a tag in a single call. This is useful for content caching where one upstream entity (a product, a user, a CMS document) is referenced by many cache entries:
import { Cacheable } from 'cacheable'; const cache = new Cacheable({ tags: true }); await cache.set('page:/products', html, { ttl: '10m', tags: ['entity:42', 'collection:products'] }); await cache.set('page:/products/42', detailHtml, { ttl: '10m', tags: ['entity:42'] }); // entity 42 changed - purge everything that referenced it await cache.tags.invalidateTag('entity:42'); await cache.get('page:/products'); // undefined await cache.get('page:/products/42'); // undefined
You can also pass tags per item with setMany, and invalidate several tags at once:
await cache.setMany([ { key: 'user:1', value: userOne, tags: ['users'] }, { key: 'user:2', value: userTwo, tags: ['users', 'org:7'] }, ]); await cache.tags.invalidateTags(['users', 'org:7']);
Tag functionality lives on the tags service — an instance of the CacheTags class from @cacheable/utils that is created by default in the constructor. Invalidation uses a lazy, constant-time model: invalidateTag simply bumps a version counter for the tag, no matter how many entries reference it. Each tagged entry stores a snapshot of its tags' versions, and on the next get / getMany the snapshot is compared to the live versions. If any tag has been bumped since, the entry is treated as a miss and removed from both the primary and secondary stores (and a delete is published via sync when enabled). The trade-off is one additional tag-store read per cache lookup while the tag service is enabled.
Tag metadata is stored in the secondary store when one is configured, otherwise in the primary store. With a shared secondary store (such as Redis), an invalidation performed by one instance is seen by every instance:
import { Cacheable } from 'cacheable'; import KeyvRedis from '@keyv/redis'; // enable tags on every instance that shares the store - writers and readers const writer = new Cacheable({ secondary: new KeyvRedis('redis://localhost:6379'), tags: true }); const reader = new Cacheable({ secondary: new KeyvRedis('redis://localhost:6379'), tags: true }); await writer.set('page:/products', html, { tags: ['entity:42'] }); await writer.tags.invalidateTag('entity:42'); await reader.get('page:/products'); // undefined - stale copy is also purged from reader's primary
The tag service is disabled by default so untagged workloads pay no extra cost, and you have to turn it on to use it — either with the tags: true option or by setting cache.tags.enabled = true. While disabled, all tag operations are no-ops: values set with tags are stored without tag tracking and invalidations have no effect. The service never enables itself, which keeps behavior predictable across distributed instances — enable it on every instance that shares the store (writers and readers) so invalidations are honored and tag snapshots are cleaned up consistently.
The full CacheTags API is available on the service:
await cache.tags.getTags('page:/products'); // ['entity:42', 'collection:products'] await cache.tags.getKeysByTag('entity:42'); // keys referencing a tag (debugging / tests) cache.tags.enabled; // whether freshness checks run on get / getMany
The following options are available for you to configure cacheable:
primary: The primary store for the cache (layer 1) defaults to in-memory by Keyv.secondary: The secondary store for the cache (layer 2) usually a persistent cache by Keyv.nonBlocking: If the secondary store is non-blocking. Default is false.stats: To enable statistics for this instance. Default is false.ttl: The default time to live for the cache in milliseconds. Default is undefined which is disabled.maxTtl: The maximum time to live for any cache entry. When set, TTLs exceeding this value are capped. Enforced on both primary and secondary stores. Default is undefined (no maximum).namespace: The namespace for the cache. Default is undefined.cacheId: A unique identifier for this cache instance. Used for sync filtering. Default is a random string.tags: Enables the tag service so tag-based invalidation can be used and freshness checks run on get / getMany. Tags must be explicitly enabled — while disabled, all tag operations are no-ops. Default is false.sync: Enable distributed cache synchronization. Can be:CacheableSync instanceCacheableSyncOptions object with { qified: MessageProvider | MessageProvider[] | Qified }If you want to enable statistics for your instance you can set the .stats.enabled property to true in the options. This will enable statistics for your instance and you can get the statistics by calling the stats property. Here are the following property statistics:
hits: The number of hits in the cache.misses: The number of misses in the cache.sets: The number of sets in the cache.deletes: The number of deletes in the cache.clears: The number of clears in the cache.errors: The number of errors in the cache.count: The number of keys in the cache.vsize: The estimated byte size of the values in the cache.ksize: The estimated byte size of the keys in the cache.You can clear / reset the stats by calling the .stats.reset() method.
This does not enable statistics for your layer 2 cache as that is a distributed cache.
set(key, value, ttlOrOptions?): Sets a value in the cache. The third argument can be a ttl or an options object such as { ttl: '1h', tags: ['entity:42'] }.setMany([{key, value, ttl?, tags?}]): Sets multiple values in the cache.get(key): Gets a value from the cache.get(key, { raw: true }): Gets a raw value from the cache.getMany([keys]): Gets multiple values from the cache.getMany([keys], { raw: true }): Gets multiple raw values from the cache.has(key): Checks if a value exists in the cache.hasMany([keys]): Checks if multiple values exist in the cache.take(key): Takes a value from the cache and deletes it.takeMany([keys]): Takes multiple values from the cache and deletes them.delete(key): Deletes a value from the cache.deleteMany([keys]): Deletes multiple values from the cache.clear(): Clears the cache stores. Be careful with this as it will clear both layer 1 and layer 2.tags: The CacheTags service from @cacheable/utils used for tag-based invalidation, such as tags.invalidateTag(tag), tags.invalidateTags([tags]), tags.getTags(key), tags.getKeysByTag(tag), and tags.enabled.wrap(function, WrapOptions): Wraps an async function in a cache.getOrSet(GetOrSetKey, valueFunction, GetOrSetFunctionOptions): Gets a value from cache or sets it if not found using the provided function.disconnect(): Disconnects from the cache stores.onHook(hook, callback): Sets a hook.removeHook(hook): Removes a hook.on(event, callback): Listens for an event.removeListener(event, callback): Removes a listener.hash(object: any, algorithm = 'SHA-256'): Promise<string>: Asynchronously hashes an object with a cryptographic algorithm (SHA-256, SHA-384, SHA-512). Default is SHA-256.hashSync(object: any, algorithm = 'djb2'): string: Synchronously hashes an object with a non-cryptographic algorithm (djb2, fnv1, murmer, crc32). Default is djb2.getStaticInstance(options?): Static. Gets a shared singleton instance, creating it on the first call. Options apply only on first creation; passing options after init emits an error event and is otherwise ignored.setStaticInstance(instance?): Static. Sets or clears the shared singleton instance. Pass undefined to reset it.primary: The primary store for the cache (layer 1) defaults to in-memory by Keyv.secondary: The secondary store for the cache (layer 2) usually a persistent cache by Keyv.namespace: The namespace for the cache. Default is undefined. This will set the namespace for the primary and secondary stores.maxTtl: The maximum time to live for any cache entry. When set, TTLs exceeding this value are capped. Default is undefined (no maximum).nonBlocking: If the secondary store is non-blocking. Default is false.stats: The statistics for this instance which includes hits, misses, sets, deletes, clears, errors, count, vsize, ksize.If you want a single cache shared across your application without constructing a Cacheable instance and passing it around, use the static getStaticInstance() accessor. The first call creates the shared instance; every later call returns that same instance:
import { Cacheable } from 'cacheable'; const cache = Cacheable.getStaticInstance({ ttl: '1h' }); await cache.set('key', 'value'); // Anywhere else in your app, the same instance is returned: const same = Cacheable.getStaticInstance();
Options are only applied when the instance is first created. If you pass options again after the instance already exists, they are ignored and an error event is emitted on the instance to surface the conflict — listen with cache.on('error', ...). To reconfigure, replace the instance with setStaticInstance() (shown below).
You can replace or reset the shared instance with setStaticInstance(). Pass a Cacheable instance to swap it, or undefined to clear it so the next getStaticInstance() call creates a fresh one:
import { Cacheable } from 'cacheable'; import KeyvRedis from '@keyv/redis'; // Provide a fully configured instance as the shared one Cacheable.setStaticInstance(new Cacheable({ secondary: new KeyvRedis('redis://localhost:6379') })); // Reset back to no shared instance Cacheable.setStaticInstance(undefined);
Things to know:
clear() or disconnect() on it affects every part of your app that uses it.disconnect(), getStaticInstance() keeps returning the same (now disconnected) instance — it is not recreated automatically. Call setStaticInstance(undefined) first, then getStaticInstance() to get a fresh one.setStaticInstance(undefined) only drops the reference; it does not disconnect() or clear() the previous instance, so disconnect it first if it holds open connections.setStaticInstance().cacheable comes with a built-in in-memory cache called CacheableMemory from @cacheable/memory. This is a simple in-memory cache that is used as the primary store for cacheable. You can use this as a standalone cache or as a primary store for cacheable. Here is an example of how to use CacheableMemory:
import { CacheableMemory } from 'cacheable'; const options = { ttl: '1h', // 1 hour useClones: true, // use clones for the values (default is true) lruSize: 1000, // the size of the LRU cache (default is 0 which is unlimited) } const cache = new CacheableMemory(options); cache.set('key', 'value'); const value = cache.get('key'); // value
To learn more go to @cacheable/memory
Cacheable and CacheableMemory has a feature called wrap that comes from @cacheable/utils and allows you to wrap a function in a cache. This is useful for memoization and caching the results of a function. You can wrap a sync or async function in a cache. Here is an example of how to use the wrap function:
import { Cacheable } from 'cacheable'; const asyncFunction = async (value: number) => { return Math.random() * value; }; const cache = new Cacheable(); const options = { ttl: '1h', // 1 hour keyPrefix: 'p1', // key prefix. This is used if you have multiple functions and need to set a unique prefix. } const wrappedFunction = cache.wrap(asyncFunction, options); console.log(await wrappedFunction(2)); // 4 console.log(await wrappedFunction(2)); // 4 from cache
With Cacheable we have also included stampede protection so that a Promise based call will only be called once if multiple requests of the same are executed at the same time. Here is an example of how to test for stampede protection:
import { Cacheable } from 'cacheable'; const asyncFunction = async (value: number) => { return value; }; const cache = new Cacheable(); const options = { ttl: '1h', // 1 hour keyPrefix: 'p1', // key prefix. This is used if you have multiple functions and need to set a unique prefix. } const wrappedFunction = cache.wrap(asyncFunction, options); const promises = []; for (let i = 0; i < 10; i++) { promises.push(wrappedFunction(i)); } const results = await Promise.all(promises); // all results should be the same console.log(results); // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
In this example we are wrapping an async function in a cache with a ttl of 1 hour. This will cache the result of the function for 1 hour and then expire the value. You can also wrap a sync function in a cache:
import { CacheableMemory } from 'cacheable'; const syncFunction = (value: number) => { return value * 2; }; const cache = new CacheableMemory(); const wrappedFunction = cache.wrap(syncFunction, { ttl: '1h', key: 'syncFunction' }); console.log(wrappedFunction(2)); // 4 console.log(wrappedFunction(2)); // 4 from cache
In this example we are wrapping a sync function in a cache with a ttl of 1 hour. This will cache the result of the function for 1 hour and then expire the value. You can also set the key property in the wrap() options to set a custom key for the cache.
When an error occurs in the function it will not cache the value and will return the error. This is useful if you want to cache the results of a function but not cache the error. If you want it to cache the error you can set the cacheError property to true in the wrap() options. This is disabled by default.
import { CacheableMemory } from 'cacheable'; const syncFunction = (value: number) => { throw new Error('error'); }; const cache = new CacheableMemory(); const wrappedFunction = cache.wrap(syncFunction, { ttl: '1h', key: 'syncFunction', cacheError: true }); console.log(wrappedFunction()); // error console.log(wrappedFunction()); // error from cache
If you would like to generate your own key for the wrapped function you can set the createKey property in the wrap() options. This is useful if you want to generate a key based on the arguments of the function or any other criteria.
const cache = new Cacheable(); const options: WrapOptions = { cache, keyPrefix: 'test', createKey: (function_, arguments_, options: WrapOptions) => `customKey:${options?.keyPrefix}:${arguments_[0]}`, }; const wrapped = wrap((argument: string) => `Result for ${argument}`, options); const result1 = await wrapped('arg1'); const result2 = await wrapped('arg1'); // Should hit the cache console.log(result1); // Result for arg1 console.log(result2); // Result for arg1 (from cache)
We will pass in the function that is being wrapped, the arguments passed to the function, and the options used to wrap the function. You can then use these to generate a custom key for the cache.
To learn more visit @cacheable/utils
The getOrSet method that comes from @cacheable/utils provides a convenient way to implement the cache-aside pattern. It attempts to retrieve a value from cache, and if not found, calls the provided function to compute the value and store it in cache before returning it. Here are the options:
export type GetOrSetFunctionOptions = { ttl?: number | string | { primary?: number | string; secondary?: number | string }; cacheErrors?: boolean; throwErrors?: boolean; nonBlocking?: boolean; };
The ttl also accepts a per-store object such as { primary: '10s', secondary: '5m' } to give the primary and secondary stores different expirations for this operation.
The nonBlocking option allows you to override the instance-level nonBlocking setting for the get call within getOrSet. When set to false, the get will block and wait for a response from the secondary store before deciding whether to call the provided function. When set to true, the primary store returns immediately and syncs from secondary in the background.
Here is an example of how to use the getOrSet method:
import { Cacheable } from 'cacheable'; const cache = new Cacheable(); // Use getOrSet to fetch user data const function_ = async () => Math.random() * 100; const value = await cache.getOrSet('randomValue', function_, { ttl: '1h' }); console.log(value); // e.g. 42.123456789
You can also use a function to compute the key for the function:
import { Cacheable, GetOrSetOptions } from 'cacheable'; const cache = new Cacheable(); // Function to generate a key based on options const generateKey = (options?: GetOrSetOptions) => { return `custom_key_:${options?.cacheId || 'default'}`; }; const function_ = async () => Math.random() * 100; const value = await cache.getOrSet(generateKey(), function_, { ttl: '1h' });
To learn more go to @cacheable/utils
cacheable is now using @cacheable/utils and @cacheable/memory for its core functionality as we are moving to this modular architecture and plan to eventually have these modules across cache-manager and flat-cache. In addition there are some breaking changes:
get() and getMany() no longer have the raw option but instead we have built out getRaw() and getManyRaw() to use.get related functions now support nonBlocking which means if nonBlocking: true the primary store will return what it has and then in the background will work to sync from secondary storage for any misses. You can disable this by setting at the get function level the option nonBlocking: false which will look for any missing keys in the secondary.Keyv v5.5+ is now the recommended supported version as we are using its native getMany*, getRaw*, and hasMany methods for improved performanceWrap and getOrSet have been updated with more robust options including the ability to use your own serialize function for creating the key in wrap.hash has been split into async (hash() and hashToNumber()) and sync (hashSync() and hashToNumberSync()) methods. MD5 support has been removed. Now uses Hashery library with support for additional algorithms (SHA-384, FNV1, MURMER, CRC32).You can contribute by forking the repo and submitting a pull request. Please make sure to add tests and update the documentation. To learn more about how to contribute go to our main README https://github.com/jaredwray/cacheable. This will talk about how to Open a Pull Request, Ask a Question, or Post an Issue.