@nathapp/nestjs-cache
v4.1.0
Published
nestjs-cache
Readme
@nathapp/nestjs-cache
Enterprise-grade caching module for NestJS with multi-tier storage, tag-based invalidation, and distributed synchronization.
Built on top of Cacheable and Keyv for high-performance L1/L2 caching.
Features
- Multiple strategies: Memory, Distributed (Redis), Multi-Tier (L1 + L2), Legacy
- Tag-based invalidation: O(m) bulk cache clearing using Redis Sets
- Distributed sync: Automatic cross-instance cache synchronization via CacheableSync
- Unified API: Single
invalidate()method with mode overloads (key,pattern,tag) - Redis URI support: Parse
redis://andrediss://URIs into connection objects - Metrics hooks: Monitor hits, misses, sets, deletes, and errors
- Health checks: Built-in health check interface
- Namespace support: Isolated cache namespaces with auto-prefixed tags
- Graceful degradation: Fallback to memory when Redis is unavailable (MULTI_TIER)
- Non-blocking mode: Background L2 updates for low-latency responses
Installation
npm install @nathapp/nestjs-cache cacheable @keyv/redis
# For distributed sync (MULTI_TIER with multiple instances):
npm install @qified/redisQuick Start
Memory Strategy (Single Instance)
import { CacheModule, CacheStrategy } from '@nathapp/nestjs-cache';
@Module({
imports: [
CacheModule.register({
strategy: CacheStrategy.MEMORY,
memory: {
lruSize: 1000,
ttl: '5m',
},
}),
],
})
export class AppModule {}Distributed Strategy (Redis Only)
CacheModule.register({
strategy: CacheStrategy.DISTRIBUTED,
distributed: {
host: 'localhost',
port: 6379,
ttl: '10m',
},
})Multi-Tier Strategy (L1 Memory + L2 Redis)
CacheModule.register({
strategy: CacheStrategy.MULTI_TIER,
memory: {
lruSize: 500,
ttl: '1m',
},
distributed: {
url: 'redis://localhost:6379',
ttl: '10m',
},
})Redis URI Configuration
CacheModule.register({
strategy: CacheStrategy.DISTRIBUTED,
distributed: {
url: 'redis://username:[email protected]:6379/0',
ttl: '10m',
},
})Individual fields override URI-parsed values:
distributed: {
url: 'redis://localhost:6379',
port: 6380, // Overrides port from URI
}Usage
Basic Cache Operations
import { Injectable } from '@nestjs/common';
import { CacheManager } from '@nathapp/nestjs-cache';
@Injectable()
export class UserService {
constructor(private readonly cache: CacheManager) {}
async getUser(id: string) {
// Get with resolver (cache-aside pattern)
return this.cache.get(
['USER', id],
async () => this.userRepository.findById(id),
60000, // TTL in ms
);
}
async updateUser(id: string, data: UpdateUserDto) {
await this.userRepository.update(id, data);
// Invalidate cached user
await this.cache.invalidate(['USER', id]);
}
}Tag-Based Invalidation
Tags allow efficient bulk invalidation of related cache entries. Instead of scanning the entire keyspace (O(n)), tags use Redis Sets for O(m) complexity where m = number of tagged keys.
@Injectable()
export class PostService {
constructor(private readonly cache: CacheManager) {}
async getPost(userId: string, postId: string) {
return this.cache.get(
['USER', userId, 'POST', postId],
async () => this.postRepository.findOne(postId),
{ tags: ['USER:' + userId + ':POSTS', 'USER:' + userId] },
);
}
async setPost(userId: string, postId: string, data: Post) {
await this.cache.set(
['USER', userId, 'POST', postId],
data,
60000,
{ tags: ['USER:' + userId + ':POSTS', 'USER:' + userId] },
);
}
async deleteAllUserPosts(userId: string) {
// Invalidate all posts for a user in one call (O(m))
await this.cache.invalidate(
['USER', userId, 'POSTS'],
{ mode: 'tag' },
);
}
async deleteAllUserData(userId: string) {
// Invalidate everything for a user
await this.cache.invalidate(
['USER', userId],
{ mode: 'tag' },
);
}
}get() Overloads
// Basic get (returns cached value or null)
const value = await cache.get<User>(['USER', '123']);
// Get with resolver (cache-aside)
const user = await cache.get(['USER', '123'], () => fetchUser('123'));
// Get with resolver and explicit TTL
const user = await cache.get(['USER', '123'], () => fetchUser('123'), 60000);
// Get with resolver and tags (default 300s TTL)
const user = await cache.get(['USER', '123'], () => fetchUser('123'), {
tags: ['USER:123'],
});
// Get with resolver, TTL, and tags
const user = await cache.get(['USER', '123'], () => fetchUser('123'), 60000, {
tags: ['USER:123'],
});Unified invalidate() API
// Exact key deletion (default)
await cache.invalidate(['USER', '123']);
// Explicit mode: exact key
await cache.invalidate(['USER', '123'], { mode: 'key' });
// Tag-based invalidation (O(m))
await cache.invalidate(['USER:123:POSTS'], { mode: 'tag' });
// Backward compatibility: group=true now maps to tag mode
await cache.invalidate(['USER', '123'], true);Batch Invalidation
await cache.mInvalidate([
{ cacheKey: ['USER', '123'], mode: 'key' },
{ cacheKey: ['USER:456:POSTS'], mode: 'tag' },
{ cacheKey: ['SESSION:789'], mode: 'key' },
]);Namespaced Cache
const postCache = cache.namespace('POSTS');
// All keys are auto-prefixed: POSTS:123
await postCache.set(['123'], postData, 60000, {
tags: ['AUTHOR:456'], // Stored as POSTS:AUTHOR:456
});
// Invalidate within namespace
await postCache.invalidate(['AUTHOR:456'], { mode: 'tag' });
// Invalidate all keys in namespace
await postCache.invalidateAll();Metrics
CacheModule.register({
strategy: CacheStrategy.DISTRIBUTED,
distributed: { host: 'localhost', port: 6379 },
metrics: {
onCacheHit: (ctx) => console.log(`HIT ${ctx.key} ${ctx.durationMs}ms`),
onCacheMiss: (ctx) => console.log(`MISS ${ctx.key}`),
onCacheSet: (ctx) => console.log(`SET ${ctx.key} ttl=${ctx.ttl}`),
onCacheDelete: (ctx) => console.log(`DEL ${ctx.key}`),
onCacheError: (ctx) => console.error(`ERR ${ctx.key}`, ctx.error),
},
})Health Check
const health = await cache.isHealthy();
// {
// healthy: true,
// timestamp: Date,
// connections: [
// { name: 'memory', status: 'connected' },
// { name: 'redis', status: 'connected', latencyMs: 2 }
// ],
// stats: { ... }
// }Distributed Synchronization (CacheableSync)
For multi-instance deployments using MULTI_TIER, cache synchronization is automatically enabled. When one instance sets or deletes a key, all other instances update their L1 (memory) caches via Redis Pub/Sub.
CacheModule.register({
strategy: CacheStrategy.MULTI_TIER,
memory: { ttl: '1m' },
distributed: { host: 'localhost', port: 6379, ttl: '10m' },
// sync is enabled by default for MULTI_TIER
// To disable: sync: { enabled: false }
})To use a custom namespace for sync isolation:
sync: {
enabled: true,
namespace: 'my-service',
}Tag Configuration
CacheModule.register({
strategy: CacheStrategy.DISTRIBUTED,
distributed: { host: 'localhost', port: 6379 },
tags: {
enabled: true, // default: true when Redis available
tagPrefix: '_TAG:', // prefix for tag sets in Redis
tagTtl: 86400, // tag set TTL in seconds (24h)
maxTagsPerKey: 10, // max tags per cache entry
maxKeysPerTag: 10000, // safety limit per tag set
},
})Cache Interceptor
Automatically cache HTTP responses:
import { CacheInterceptor, CacheReqOptions } from '@nathapp/nestjs-cache';
@Controller('users')
@UseInterceptors(CacheInterceptor)
export class UserController {
@Get(':id')
@CacheReqOptions({ ttl: 60000 })
getUser(@Param('id') id: string) {
return this.userService.findById(id);
}
}Strategies Comparison
| Feature | MEMORY | DISTRIBUTED | MULTI_TIER | LEGACY | |---------|--------|------------|------------|--------| | L1 (Memory) | ✅ | ❌ | ✅ | ✅ | | L2 (Redis) | ❌ | ✅ | ✅ | ❌ | | Tag Invalidation | ❌ | ✅ | ✅ | ⚠️ Limited | | Cross-Instance Sync | N/A | N/A | ✅ Auto | ❌ | | Graceful Degradation | N/A | ✅ | ✅ | N/A | | Non-Blocking | N/A | N/A | ✅ | N/A | | Recommended | Dev/Test | Single Redis | Production | Migration only |
Lifecycle and Resource Ownership
Enhanced strategies (MEMORY, DISTRIBUTED, MULTI_TIER) build a Cacheable
instance inside the module. When Nest shuts the application down,
CacheModule releases everything it created.
Owned vs. consumer-provided resources
| Resource | Created by | Closed by |
|----------|------------|-----------|
| Cacheable instance + its Keyv store (@keyv/redis) | @nathapp/nestjs-cache | cacheable.disconnect() invoked by CacheModule on shutdown |
| RedisMessageProvider sync subscriber (@qified/redis) | @nathapp/nestjs-cache | Provider disconnect() invoked by CacheModule on shutdown |
| invalidator broker clients (LEGACY invalidateOptions.enabled) | @nathapp/nestjs-cache | Invalidation manager close() invoked by CacheModule on shutdown |
| External Keyv store / connection you inject yourself | Your application | Your application (CacheModule never touches it) |
cacheable.disconnect() does not close the @qified/redis
RedisMessageProvider subscriber, so the module closes it explicitly.
MEMORY has no sync provider, and LEGACY has no Cacheable instance, so those
disconnects are simply skipped.
Shutdown behavior
- Runs on
OnApplicationShutdown(app.close(), or the shutdown signal). - The whole teardown is memoized: repeated/concurrent shutdowns disconnect each owned resource exactly once.
- Each resource is closed independently. If one disconnect throws or rejects, the remaining resources are still closed and Nest's shutdown is not aborted.
- Failures are logged with a sanitized message (no credentials or error payloads).
disconnectCacheableInstance()is an internal helper and is not part of the public API surface. Do not depend on it directly.
Migration from Legacy
Before (Legacy)
import { CacheModule } from '@nathapp/nestjs-cache';
CacheModule.register({
// No strategy = LEGACY by default
ttl: 300,
})After (Enhanced)
import { CacheModule, CacheStrategy } from '@nathapp/nestjs-cache';
CacheModule.register({
strategy: CacheStrategy.MULTI_TIER,
memory: { lruSize: 1000, ttl: '5m' },
distributed: { host: 'localhost', port: 6379, ttl: '10m' },
})The CacheManager API is backward compatible — get(), set(), invalidate() all work the same way with enhanced strategies.
Cache Key Sanitization
Cache key parts are percent-encoded before being joined with :. As of the current release, the
non-legacy sanitizer also encodes : itself (: → %3a, uppercased to %3A in resolved keys),
so parts containing a colon can no longer collide with different part splits — e.g.
['a:b', 'c'] and ['a', 'b:c'] now resolve to different keys (A%3AB:C vs A:B:C).
⚠️ This changes resolved keys for any part containing
:. Existing cache entries built with such parts will miss once (a one-time invalidation / cold cache). If you have pinned keys that must keep their old form, build them with the legacy sanitizer:sanitizeCacheKeyPart(part, true)(legacy mode leaves:untouched). An empty parts array (resolveCacheKey([])) now throws instead of producing an empty key.
Deprecated APIs
⚠️ The following APIs are deprecated and will be removed in a future major release. Please migrate to the recommended alternatives.
InvalidateStrategy.BROKER
Deprecated. Cross-instance cache invalidation is now handled by CacheableSync (built into Cacheable).
// ❌ Deprecated
CacheModule.register({
invalidate: {
strategy: InvalidateStrategy.BROKER,
transport: Transport.REDIS,
options: { host: 'localhost', port: 6379 },
},
})
// ✅ Replacement — use MULTI_TIER strategy (sync is automatic)
CacheModule.register({
strategy: CacheStrategy.MULTI_TIER,
distributed: { host: 'localhost', port: 6379 },
})CacheInvalidateManager / RedisCacheInvalidateManager
Deprecated. These classes are replaced by CacheableSync for enhanced strategies. They remain functional only for CacheStrategy.LEGACY.
invalidateByTag() / invalidateByTags()
Deprecated. Use the unified invalidate() API with { mode: 'tag' } instead.
// ❌ Deprecated
await cache.invalidateByTag('USER:123:POSTS');
await cache.invalidateByTags(['USER:123:POSTS', 'USER:456:POSTS']);
// ✅ Replacement
await cache.invalidate(['USER:123:POSTS'], { mode: 'tag' });
await cache.mInvalidate([
{ cacheKey: ['USER:123:POSTS'], mode: 'tag' },
{ cacheKey: ['USER:456:POSTS'], mode: 'tag' },
]);invalidateByPattern()
Deprecated. Pattern-based invalidation uses O(n) SCAN which doesn't scale. Use tag-based invalidation instead.
// ❌ Deprecated — O(n) keyspace scan
await cache.invalidateByPattern('USER:123:*');
// ✅ Replacement — O(m) tag-based
// First, set entries with tags:
await cache.set(['USER', '123', 'POST', '1'], data, 60000, {
tags: ['USER:123:POSTS'],
});
// Then invalidate by tag:
await cache.invalidate(['USER:123:POSTS'], { mode: 'tag' });Note:
invalidateByPattern()always deletes matching keys via SCAN in enhanced mode. It does not consult the tag registry, even when one is configured, so a glob pattern is never treated as a literal tag.
buildRedisUrl()
Deprecated. Use resolveRedisConnection() to parse URIs into connection objects instead of building URL strings.
// ❌ Deprecated
const url = buildRedisUrl({ host: 'localhost', port: 6379 });
// ✅ Replacement
const connection = resolveRedisConnection({ host: 'localhost', port: 6379 });dipatch() (typo alias)
Deprecated. Use the correctly spelled dispatch() method.
lib/inteceptor/ (typo directory)
Deprecated. Imports from lib/inteceptor/ still work via barrel re-export but the implementation has moved to lib/interceptor/.
CacheStrategy.LEGACY
Deprecated. The legacy strategy using @nestjs/cache-manager is maintained for backward compatibility but will be removed in a future major release. Migrate to MEMORY, DISTRIBUTED, or MULTI_TIER.
API Reference
CacheManager
| Method | Description |
|--------|-------------|
| get<T>(keys, resolver?, ttlOrOptions?, options?) | Get or resolve+cache a value |
| getOnly<T>(keys) | Get without resolver |
| set<T>(keys, value, ttl?, options?) | Set a value with optional tags |
| mset<T>(list) | Set multiple values |
| mget(keys) | Get multiple values |
| invalidate(key, options?) | Unified invalidation (key/pattern/tag modes) |
| mInvalidate(list) | Batch invalidation with mixed modes |
| reset() | Clear all cache entries |
| touch(keys, ttl?) | Extend TTL of a cached item |
| namespace(name) | Create a namespaced cache manager |
| isHealthy() | Health check |
| getStats() | Cache statistics (enhanced mode) |
| isEnhancedMode() | Check if using cacheable |
| resolveKey(keyParts) | Resolve key parts to string |
CacheModule
| Method | Description |
|--------|-------------|
| register(options) | Synchronous registration |
| registerAsync(options) | Async registration with factory |
License
See the root monorepo license.
