Allows to create configurable caches for NodeJS applications with automatic cache loading. Internal caching is supported by an in-memory cache or Redis, with automatic fallback in case of Redis disconnection.
Cache Flow also provides a @Cacheable decorator for Typescript projects (inspired from Java/Spring), packing all
the caching features of this library into a single line of code, completely transparently for method callers.
Requirements: Node.js 22 or later. The library is tested against Node.js 22 (Jod) and 24 (Krypton), the currently supported LTS release lines.
npm install --save cache-flow
reflect-metadata shim is required:npm install --save reflect-metadata
Make sure to import it before you use cache-flow (best is to import it at the top of your index file):
import 'reflect-metadata';
SimpleCache.ts1.a. Typescript example:
import { CacheLoader } from 'cache-flow';
class SimpleCache extends CacheLoader<string, string> {
constructor() {
super('simple-cache-1', {
expirationTime: 3600 // 1h in seconds
});
}
protected async load(key: string): Promise<string> {
const now = new Date();
return key + '-' + now.getTime();
}
}
Also see the code example
1.b. ES6 Javascript example:
Even though it does not provide all the genericity Cache Flow comes with when used in a Typescript environment, native javascript is also completely supported:
import { CacheLoader } from 'cache-flow';
// also works with
// const { CacheLoader } = require('cache-flow');
class ES6ExampleCache extends CacheLoader {
constructor() {
super('es6-example-cache', {
expirationTime: 3600 // 1h in seconds
});
}
load(key) {
const now = new Date();
return key + '-' + now.getTime();
}
}
Also see the code example
const cache = new SimpleCache();
const myValue = await cache.get('myKey');
setTimeout(async function () {
const myValue2 = await cache.get('myKey');
console.log(myValue);
console.log(myValue2); // myValue2 has the same value as myValue!
}, 3000);
Unlike lots of cache libraries, with Cache Flow, you don't need to manually check if a key exists in your cache before trying to get it, and set the value yourself afterwards.
When you call the get function of your cache, Cache Flow automatically takes care of calling the load function
your previously defined, and stores the value for a later call.
So if you call get right after, giving the same key, it will bypass the loader function, and directly pull the value
from the inner cache.
class UserProfileCache extends CacheLoader<User, UserProfile> {
constructor() {
super('user-profile-cache', {
expirationTime: 3600 // 1h in seconds
});
}
protected async load(user: User): Promise<UserProfile> {
const profile = await userProfileService.getProfile(user);
return profile;
}
protected keyToString(user: User): string {
return user.id;
}
}
Also see the code example
Cache Flow allows you to define caches with more complex keys and values, which can be objects, arrays, ...
This way, you can define your own cache key structures:
class CustomCache extends CacheLoader<MyCacheKey, CachedObject> {
constructor() {
super('custom-cache-key', {
expirationTime: 3600 // 1h in seconds
});
}
protected async load(key: MyCacheKey): Promise<CachedObject> {
const result = await doSomething(key);
return result;
}
}
interface MyCacheKey {
id: string;
someField: string;
someOtherField: number;
}
A cache defined with such a key will compute a hash of your key objects to use as the internal cache key. You can also
define a custom keyToString function, transforming your key into a unique string identifying your key. It can be as
simple as an ID, or a combination of several parameters that define your key, like:
class CustomCache extends CacheLoader<Identifier, string> {
protected keyToString(identifier: Identifier): string {
return `${identifier.code}-${identifier.language}`;
}
}
When using Typescript, you have access to a powerful shortcut to caching: you can annotate your class method
with @Cacheable. This automatically wraps your method call with a caching layer, meaning when calling your method,
Cache Flow will take care of checking for an existing cached value, and otherwise will execute the method to get
one. With this, adding a cache is completely transparent for anyone calling this method, without any other required
change than annotating your method with the @Cacheable decorator.
Here is an example:
class CacheableExampleClass {
private readonly id: string;
constructor(id: string) {
this.id = id;
}
@Cacheable()
public async getResult(prefix: string, value: number): Promise<string> {
await sleep(100);
const now = new Date();
return `${this.id}-${prefix}-${now.getTime()}${now.getMilliseconds()}-${value * 3}`;
}
}
Also see the code example
@Cacheable provides the same flexibility as manually declaring a class cache: you can configure the cacheId,
expirationTime and maxSize parameters, as well as the argsToKey, serialize and deserialize functions (see
documentation below).
By default, @Cacheable will use object-hash on the array of arguments to
create a cache key. In cases where you have more complex objects as your cache key, and only want to use a subset of
these objects, you can define a custom way of inferring the cache key from arguments:
@Cacheable({
argsToKey: (user: User) => {
return `${user.id}-${user.lastUpdate}`;
},
options: {
expirationTime: 3600
}
})
If argsToKey returns a string, this string is directly used as the cache key. Otherwise, the cache computes a hash of
the returned value and uses it as the cache key. Make sure your argsToKey function actually returns a value: one that
returns nothing maps every call to the same cache key.
The cache backing a @Cacheable method is created once per decorated method, when the class is defined, and the cache
key is derived from the method arguments only. All instances of the class therefore share the same cache entries, and
two instances called with equal arguments get the same cached value even if their internal state differs:
class Greeter {
constructor(private readonly name: string) {
}
@Cacheable()
public async greet(greeting: string): Promise<string> {
return `${greeting}, ${this.name}`;
}
}
await new Greeter('Alice').greet('Hello'); // 'Hello, Alice', loaded
await new Greeter('Bob').greet('Hello'); // 'Hello, Alice' — same key, served from the cache
If a method's result depends on instance state, include that state in the cache key with argsToKey, or use a
CacheLoader whose key covers it.
By default, @Cacheable uses an expirationTime of 3600 seconds (1 hour), which differs from the CacheLoader default
of 24 hours. Set options.expirationTime explicitly if you rely on a particular value.
You can find more examples in the examples folder of the source repository:
https://github.com/abourdin/cache-flow/tree/master/examples
Cache Flow also supports distributed caching by using Redis as the caching engine. To have your cache instances use Redis behind the scenes, Cache Flow must be configured as part of the startup of your application, before any cache is instantiated.
import { CacheFlow } from 'cache-flow';
CacheFlow.configure({
redis: {
host: 'your.redis.server.com',
port: 6379
}
});
// all set, now you can start caching!
const cache = new SimpleCache();
Both host and port are required. configure throws if a redis configuration is given with only one of them, rather
than leaving your caches silently local to each process:
CacheFlow.configure({
redis: {
host: 'your.redis.server.com'
// no port: throws Error: Invalid Cache Flow Redis configuration: both 'host' and 'port' are required
}
});
Omitting the redis block entirely is still perfectly valid, and keeps all caches in in-memory LRU mode.
But what if your Redis server has to restart or goes down? Don't worry, Cache Flow has got you covered!
In case your Redis server temporarily goes down, all your caches will automatically fallback to an in-memory LRU cache, until your Redis server is back online. As soon as your caches can reconnect, they'll switch back to using Redis. This way, your caching layer keeps serving requests throughout.
Note that each switch between Redis and LRU starts from an empty cache, so the first get for any given key after a
switch calls your loader again. Availability is preserved; cached entries are not carried across.
Cache Flow comes with a default logger which can log various information about what happens with your caches (when values are get, loaded, some errors, ...)
By default, Cache Flow will only log errors to console.error, but you can provide your own logger in the initial
configuration of the library, like log4js
, winston, ...
See the example below with log4js:
import { CacheFlow } from 'cache-flow';
import * as log4js from 'log4js';
CacheFlow.configure({
logger: log4js.getLogger()
});
You can also simply pass the console to get all logs output to the stdout, including debug logs, like so:
import { CacheFlow } from 'cache-flow';
CacheFlow.configure({
logger: console
});
CacheLoader constructor parameters:cacheId: a unique string identifying each cache. If shared between 2 caches or more, their keys might conflict, and
cause deserialization errors when trying to get a key stored by another cache.options:
expirationTime: the time in seconds during which cache entries will be retained before being evicted (default:
86400, i.e. 24 hours). Note that @Cacheable defaults to 3600 (1 hour) instead.maxSize: the maximum number of entries stored in the cache when running in LRU mode (default: 1000). Once maximum
is reached and a new entry is added to the cache, it replaces the least recently used. This option has no effect in
Redis mode, where entry count is bounded by your Redis server configuration.CacheFlow.configure(configuration) configuration object parameter:redis: optional. When given, both host and port are required, and configure throws otherwise.
host: the Redis server hostnameport: the Redis server portdb: the Redis database index to use (default: 0)logger: a logger instance matching LoggerInterface. When configure is called without a logger, the logger is
reset to the default one, which only writes errors to console.error.When extending CacheLoader, your cache will come with default serialization and deserialization implementations for
the
serialize and deserialize methods, which take care of storing objects and arrays as JSON in the inner cache, and
transform them back when getting values out of the cache.
In the case where you need to implement your own serialization and deserialization, for example to cache specific
framework classes or entities, your CacheLoader implementation can override serialize and deserialize methods:
class MyEntityCache extends CacheLoader<string, MyEntity> {
constructor() {
super('my-entity-cache', {
expirationTime: 3600 // 1h in seconds
});
}
protected async load(id: string): Promise<MyEntity> {
const entity = await repository.findById(id);
return entity;
}
protected serialize(entity: MyEntity): any {
return entity.toJSON();
}
protected deserialize(serialized: any): MyEntity {
return MyEntity.fromJSON(serialized);
}
}
Cache Flow treats null and undefined as "nothing cached for this key", and every other value — including falsy
ones — as a cacheable value:
| Value returned by your loader | Served from the cache on the next get |
|---|---|
0, '', false |
yes |
| any string, object or array | yes |
null |
no, your loader is called on every get |
undefined |
no, your loader is called on every get |
So a loader that legitimately resolves to 0, '' or false is cached and served like any other value:
class StockCache extends CacheLoader<string, number> {
constructor() {
super('stock-cache', {
expirationTime: 60
});
}
protected async load(sku: string): Promise<number> {
return warehouse.countAvailable(sku); // may legitimately be 0
}
}
const cache = new StockCache();
await cache.get('sku-123'); // 0, counted by the warehouse
await cache.get('sku-123'); // 0, served from the cache — your loader is not called again
If your loader returns null or undefined to mean "there is no value for this key", nothing is stored and nothing is
ever served from the cache for it, so your loader runs on every call. When those lookups are expensive, return a sentinel
value your own code recognises instead of null, so the negative result gets cached too.
Two related behaviours worth knowing:
get(undefined) returns undefined straight away, without calling your loader.set(key, undefined) and set(undefined, value) store nothing and log an error.Objects and arrays are stored as JSON in both LRU and Redis mode, so Date values come back as ISO strings, and Map,
Set and class instances lose their type. Override serialize and deserialize (see above) when you need them restored.
NaN and Infinity are the one case where the two modes differ: LRU mode caches and returns them unchanged, while Redis
mode stores them as null and therefore treats them as absent, calling your loader on every get.
Cache Flow is compatible with your favorite Typescript DI framework, like typedi, tsyringe, InversifyJS...
For example, with typedi:
import { CacheLoader } from 'cache-flow';
import { Inject, Service } from 'typedi';
@Service()
class UserCache extends CacheLoader<string, User> {
@Inject()
private userService: UserService;
constructor() {
super('user-cache', {
expirationTime: 3600 // 1h in seconds
});
}
protected async load(userId: string): Promise<User> {
return this.userService.getUserById(userId);
}
}
Also see the code example
Alongside CacheLoader, CacheFlow and Cacheable, the package entry point exports the types you need to annotate your
own code: CacheOptions, CacheDefinition, CacheMetadata, Metadata, CacheFlowConfiguration,
RedisCacheConfiguration, LoggerInterface and DefaultLogger.
import { CacheFlowConfiguration, LoggerInterface, Metadata } from 'cache-flow';
| Method | Example | Description |
|---|---|---|
async get(key: K, force?: boolean): Promise<V> |
myCache.get('myKey') |
Gets a value from the cache, loading it if no value is cached. If force is set to true, a new value is loaded without checking existence in the cache. |
async getWithMetadata(key: K, force?: boolean): Promise<Metadata<V>> |
myCache.getWithMetadata('myKey') |
Same as get, returning { value, cached, time }: whether the value came from the cache, and how many milliseconds the call took. |
async set(key: K, value: V): Promise<void> |
myCache.set('myKey', 'myValue') |
Sets a value in the cache for the given key |
async delete(key: K): Promise<void> |
myCache.delete('myKey') |
Evicts a key from the cache |
async exists(key: K): Promise<boolean> |
myCache.exists('myKey') |
Checks whether a value exists in the cache for the given key |
async reset(): Promise<void> |
myCache.reset() |
Clears all values from the cache |
getCacheId(): string |
myCache.getCacheId() |
Gets the cache's ID |
getCacheDefinition(): CacheDefinition |
myCache.getCacheDefinition() |
Gets the cache definition |
These are the methods you call. The ones you can override in your own CacheLoader are load (required), and
keyToString, serialize and deserialize (all optional).
| Method | Example | Description |
|---|---|---|
static configure(configuration: CacheFlowConfiguration) |
CacheFlow.configure({redis: {host: 'localhost', port: 6379}}) |
Sets the global configuration for Cache Flow and all subsequently instantiated caches. Call it before instantiating any cache. Throws on an incomplete redis configuration. |
static get(cacheId: string): CacheLoader<any, any> |
CacheFlow.get('my-cache') |
Gets cache with given cache ID. Cannot access caches created using @Cacheable annotation. |
static async delete(cacheId: string, ...key: any[]): Promise<void> |
CacheFlow.delete('my-cache', 'myKey') |
Deletes entry for given key in cache with given cache ID. For an @Cacheable cache, pass the method arguments in order: CacheFlow.delete('MyClass#myMethod', 'foo', 123). |
static async reset(cacheId: string): Promise<void> |
CacheFlow.reset('my-cache') |
Resets cache with given cache ID, including @Cacheable caches |
static async resetAll(): Promise<void> |
CacheFlow.resetAll() |
Clears all caches, including @Cacheable caches |
static getInstances(): CacheLoader<any, any>[] |
CacheFlow.getInstances() |
Gets all cache instances (except the ones created using @Cacheable annotation) |
Unlike get and getInstances, delete and reset do reach @Cacheable caches, addressed by their cache ID — which
defaults to ClassName#methodName when you do not set one.
👤 Alexandre Bourdin contact@abourdin.com
Give a ⭐️ if this project helped you!