express-memorize
v2.3.0
Published
In-memory cache middleware for Express.js. Caches GET responses with optional TTL — zero dependencies, fully typed.
Maintainers
Readme
express-memorize
Features
- Caches
GETresponses automatically when status code is2xx - Works with Express, Fastify, Koa, NestJS, Hono, Fetch API / serverless, and direct service-level usage
- Per-route TTL override and
noCachebypass maxEntriescap with LRU eviction to bound memory usage- Size metrics:
size(),byteSize(),getStats() - Service-level cache:
remember(),set(),getValue() - Pluggable serializer:
'auto'(node:v8 when available, else JSON),'json','v8', or custom - Event hooks:
set,delete,expire,evict - Cache inspection and invalidation API (
get,getAll,delete,deleteMatching,clear) - Hit counter per cache entry
X-Cache: HIT | MISS | BYPASSresponse header- Zero runtime dependencies, fully typed
Installation
npm install express-memorizeAdapters for non-Express runtimes are optional — install only what you need:
npm install fastify # only if using the Fastify adapter
npm install koa @koa/router # only if using the Koa adapter
npm install hono # only if using the Hono adapter
npm install @nestjs/common @nestjs/core rxjs # only if using the NestJS adapterQuick Start
Express
import express from 'express';
import { memorize } from 'express-memorize';
const app = express();
const cache = memorize({ ttl: 30_000 });
app.get('/users', cache(), async (req, res) => {
const users = await db.getUsers();
res.json({ data: users });
});
app.listen(3000);Fastify
import Fastify from 'fastify';
import { memorize } from 'express-memorize';
import { createFastifyPlugin } from 'express-memorize/fastify';
const app = Fastify();
const cache = memorize({ ttl: 30_000 });
await app.register(createFastifyPlugin(cache));
app.get('/users', async () => {
return usersService.findAll();
});Koa
import Koa from 'koa';
import Router from '@koa/router';
import { memorize } from 'express-memorize';
import { createKoaMiddleware } from 'express-memorize/koa';
const app = new Koa();
const router = new Router();
const cache = memorize({ ttl: 30_000 });
router.get('/users', createKoaMiddleware(cache), async (ctx) => {
ctx.body = await usersService.findAll();
});
app.use(router.routes());
app.use(router.allowedMethods());Hono
import { Hono } from 'hono';
import { memorize } from 'express-memorize';
import { createHonoMiddleware } from 'express-memorize/hono';
const app = new Hono();
const cache = memorize({ ttl: 30_000 });
app.get('/users', createHonoMiddleware(cache), async (c) => {
return c.json(await usersService.findAll());
});NestJS
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import {
MemorizeCacheKey,
MemorizeInterceptor,
MemorizeModule,
MemorizeTtl,
} from 'express-memorize/nestjs';
@Module({
imports: [MemorizeModule.forRoot({ ttl: 30_000 })],
providers: [
{
provide: APP_INTERCEPTOR,
useExisting: MemorizeInterceptor,
},
],
})
export class AppModule {}
export class UsersController {
@MemorizeCacheKey('users:list')
@MemorizeTtl(10_000)
findAll() {
return usersService.findAll();
}
}Fetch API / Serverless
import { memorize } from 'express-memorize';
import { cacheFetchHandler } from 'express-memorize/fetch';
const cache = memorize({ ttl: 30_000 });
export default cacheFetchHandler(cache, async (request) => {
const users = await usersService.findAll();
return Response.json(users);
});Service-level caching
Cache arbitrary values directly — no HTTP layer required.
const cache = memorize({ ttl: 60_000 });
// Compute-and-cache pattern
const users = await cache.remember('users:list', () => usersService.findAll());
// Explicit set/get
cache.set('config', appConfig);
const config = cache.getValue<AppConfig>('config');Serializer
The serializer option controls how values passed to set() / getValue() / remember() are stored internally. It does not affect HTTP middleware caching — adapters store response bodies as-is.
| Value | Serializes to | Handles Date, Map, Set, Buffer | Runtime |
|-------|--------------|----------------------------------------|---------|
| 'auto' (default) | Buffer (v8) or string (JSON) | Yes — when node:v8 is available | Any |
| 'json' | string | No | Any (edge runtimes, human-readable) |
| 'v8' | Buffer | Yes | Node.js / Bun — throws at construction otherwise |
| Custom object | user-defined | user-defined | Any |
// auto (default): uses node:v8 when available, falls back to JSON silently
const cache = memorize();
// Always JSON — useful for edge runtimes or when you need human-readable bodies
const cache = memorize({ serializer: 'json' });
// Always v8 — throws at construction if node:v8 is not available
const cache = memorize({ serializer: 'v8' });
// Custom serializer — bring your own (MessagePack, CBOR, etc.)
import { pack, unpack } from 'msgpackr';
const cache = memorize({
serializer: {
serialize: (v) => Buffer.from(pack(v)),
deserialize: (d) => unpack(d as Buffer),
},
});With 'v8' or 'auto', set() correctly round-trips types that JSON cannot represent:
const cache = memorize({ serializer: 'v8', ttl: Infinity });
cache.set('created', new Date());
cache.getValue<Date>('created'); // Date instance preserved
cache.set('roles', new Set(['admin', 'editor']));
cache.getValue<Set<string>>('roles'); // Set instance preservedUsage
Global middleware (Express)
const cache = memorize({ ttl: 60_000 });
app.use(cache()); // applies to all GET routesPer-route TTL override
const cache = memorize({ ttl: 60_000 }); // global: 60s
app.get('/users', cache(), handler); // 60s
app.get('/products', cache({ ttl: 10_000 }), handler); // 10s
app.get('/config', cache({ ttl: Infinity }), handler); // no expirynoCache bypass
app.get('/live-feed', cache({ noCache: true }), handler);
// Sets X-Cache: BYPASS, never reads or writes the cacheFastify route-level usage
import { createFastifyPreHandler } from 'express-memorize/fastify';
app.get(
'/users',
{
preHandler: createFastifyPreHandler(cache, { ttl: 10_000 }),
},
async () => usersService.findAll(),
);GraphQL caching
GraphQL is not a simple route-level caching problem. A single POST /graphql
endpoint can execute different operations, use variables, depend on the current
viewer, and return partial data with errors. For now, the recommended strategy
is service-level caching with remember() inside resolvers or the services
they call.
const cache = memorize({ ttl: 30_000 });
const resolvers = {
Query: {
user: (_parent, args, context) => {
const viewerScope = context.user ? `user:${context.user.id}` : 'anonymous';
return cache.remember(
`graphql:${viewerScope}:user:${args.id}`,
() => usersService.findVisibleById(args.id, context.user),
);
},
},
Mutation: {
updateUser: async (_parent, args) => {
const user = await usersService.update(args.id, args.input);
cache.deleteMatching(`graphql:*:user:${args.id}`);
return user;
},
},
};GraphQL cache key rules:
- Include every input that can change the result: operation name, normalized query or field name, variables, locale, feature flags, and any relevant authorization scope.
- Do not share cached data across users unless the resolver result is genuinely public.
- Keep mutation invalidation explicit with
delete()ordeleteMatching(); automatic invalidation is too schema-specific for a generic adapter. - Avoid caching responses that contain GraphQL errors unless your application has a deliberate policy for partial data.
- Prefer resolver or service-level caching for expensive data fetches. Operation-level caching may be considered later for public, query-only workloads with strict keying rules.
There is currently no dedicated GraphQL adapter. If one is added later, the first practical target should be Apollo Server, because its plugin lifecycle can cache complete operation responses without coupling the core package entry point to GraphQL. NestJS GraphQL, Mercurius, and Yoga integrations should stay separate implementation issues unless a shared GraphQL abstraction emerges.
NestJS decorators
Use MemorizeInterceptor on a controller or globally, then configure caching at the controller or method level.
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import {
MemorizeCacheKey,
MemorizeInterceptor,
MemorizeNoCache,
MemorizeTtl,
} from 'express-memorize/nestjs';
@Controller('users')
@UseInterceptors(MemorizeInterceptor)
@MemorizeTtl(30_000)
export class UsersController {
@Get()
@MemorizeCacheKey('users:list')
findAll() {
return usersService.findAll();
}
@Get('live')
@MemorizeNoCache()
live() {
return usersService.live();
}
}For global usage, import MemorizeModule.forRoot() and register APP_INTERCEPTOR with useExisting: MemorizeInterceptor so the interceptor receives the module's shared cache instance.
Cache invalidation
app.post('/users', (req, res) => {
users.push(req.body);
cache.delete('/users');
res.status(201).json(req.body);
});Pattern-based invalidation
Use cache.deleteMatching(pattern) to remove entries by glob pattern.
app.put('/users/:id', (req, res) => {
users.update(req.params.id, req.body);
const deleted = cache.deleteMatching(`**/users/${req.params.id}*`);
console.log(`${deleted} entries removed`);
res.json({ ok: true });
});Glob rules:
| Pattern | Behaviour |
|---------|-----------|
| * | Matches any sequence within a single path segment (does not cross /) |
| ** | Matches any sequence across path segments (crosses /) |
| ? | Matches any single character except / |
Bounding memory with maxEntries
Prevent unbounded growth by setting a maximum number of entries. When the limit is reached, the least-recently-used (LRU) entry is evicted before the new one is stored.
const cache = memorize({ ttl: 30_000, maxEntries: 1_000 });Size metrics
cache.size(); // number of active entries
cache.byteSize(); // approximate total body size in bytes
cache.getStats(); // { entries, maxEntries, byteSize }
byteSize()is an estimate based on UTF-8 encoding for strings andbyteLengthfor buffers. It may not reflect actual VM memory usage.
Inspect the cache
cache.get('/users'); // CacheInfo | null
cache.getAll(); // Record<string, CacheInfo>CacheInfo shape:
{
key: string;
body: unknown;
statusCode: number;
contentType: string;
expiresAt: number | null;
remainingTtl: number | null; // ms until expiry, null when ttl is Infinity
hits: number; // times this key was served from cache
size: number; // approximate body size in bytes
}hits starts at 1 on the initial cache miss and increments on every hit. It resets to 1 if the entry is evicted and re-cached.
Event hooks
import { MemorizeEventType } from 'express-memorize';
cache.on(MemorizeEventType.Set, (e) => console.log('stored', e.key));
cache.on(MemorizeEventType.Delete, (e) => console.log('deleted', e.key));
cache.on(MemorizeEventType.Expire, (e) => console.log('expired', e.key));
cache.on(MemorizeEventType.Evict, (e) => console.log('evicted', e.key)); // maxEntries LRU
cache.on(MemorizeEventType.Empty, () => console.log('cache is empty'));API Reference
memorize(options?)
Creates a cache instance. Returns a Memorize object.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| ttl | number | 60_000 | Time-to-live in milliseconds. Pass Infinity for no expiry. |
| maxEntries | number | undefined | Maximum number of entries. LRU eviction when reached. |
| serializer | 'auto' \| 'json' \| 'v8' \| Serializer | 'auto' | Serializer for set() / getValue(). 'auto' uses node:v8 when available, falls back to JSON. Does not affect HTTP middleware caching. |
cache(options?) / cache.express(options?)
Returns an Express RequestHandler. cache() is a backwards-compatible alias for cache.express().
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| ttl | number | global ttl | TTL override for this route. Pass Infinity for no expiry. |
| noCache | boolean | false | Skip cache entirely. Sets X-Cache: BYPASS. |
Service-level cache methods
| Method | Signature | Description |
|--------|-----------|-------------|
| remember | (key, factory, ttl?) => Promise<T> | Return cached value or call factory and cache the result. |
| set | (key, value, ttl?) => void | Store an arbitrary value. |
| getValue | (key) => T \| undefined | Retrieve a value stored via set or remember. |
Cache management
| Method | Signature | Description |
|--------|-----------|-------------|
| get | (key) => CacheInfo \| null | Returns info for a cached key. |
| getAll | () => Record<string, CacheInfo> | Returns all active entries. |
| delete | (key) => boolean | Removes a single entry. |
| deleteMatching | (pattern) => number | Removes entries matching a glob pattern. |
| clear | () => void | Removes all entries. |
| size | () => number | Number of active entries. |
| byteSize | () => number | Approximate total body size in bytes. |
| getStats | () => MemorizeStats | Aggregate stats: { entries, maxEntries, byteSize }. |
Adapters
| Import path | Export | Framework |
|-------------|--------|-----------|
| express-memorize | memorize | Core factory |
| express-memorize/express | createExpressAdapter(cache, options?) | Express |
| express-memorize/fastify | createFastifyPlugin(cache, options?), createFastifyPreHandler(cache, options?) | Fastify |
| express-memorize/koa | createKoaMiddleware(cache, options?) | Koa |
| express-memorize/nestjs | MemorizeModule, MemorizeInterceptor, decorators | NestJS |
| express-memorize/hono | createHonoMiddleware(cache, options?) | Hono |
| express-memorize/fetch | cacheFetchHandler(cache, handler, options?) | Fetch API / Serverless |
Events
| Event | Payload | When |
|-------|---------|------|
| set | { type, key, body, statusCode, contentType, expiresAt, size } | A response is stored |
| delete | { type, key } | Manual removal via delete, deleteMatching, or clear |
| expire | { type, key } | TTL timer fires or lazy expiry is detected |
| evict | { type, key } | LRU eviction due to maxEntries limit |
| empty | { type } | Last entry removed, cache is now empty |
Response Headers
| Header | Value | Description |
|--------|-------|-------------|
| X-Cache | HIT | Response served from cache |
| X-Cache | MISS | Response computed and stored |
| X-Cache | BYPASS | Cache skipped — noCache: true |
Behavior
- Only
GETrequests are cached. All other methods bypass the cache entirely. - Only responses with a
2xxstatus code are stored. - All middleware and adapter instances created from the same
memorize()call share the same store. - Two separate
memorize()calls produce independent stores. - Byte size is an approximation — strings use UTF-8 encoding, objects use
JSON.stringifylength.
