A high-performance, tag-based Redis caching middleware for Express applications. Efficiently cache responses and invalidate them by tags.
- Tag-based Invalidation: Invalidate multiple cache entries at once by targeting shared tags.
- Redis-backed: Fast, persistent caching using Redis.
- Express Middleware: Easy integration into any Express project.
- TypeScript Support: Fully typed for a better developer experience.
- Context Awareness: Support for multi-tenant applications using app context prefixes.
- Cross-Service Operations: Read, invalidate, and manage caches across different services sharing the same Redis instance.
npm install express-tag-cache redis expressNote: redis and express are peer dependencies.
Warning
Breaking Changes in v2.0.0 (Major Release)
The second argument of the middleware invalidate() method has changed from a string appContext to an options object options?: TagcacheMiddlewareInvalidateOpts.
- v1.x (Legacy):
invalidate(['users'], 'user-service') - v2.x (Modern):
invalidate(['users'], { appContext: 'user-service' })
If you were passing a custom string appContext in your middleware invalidations, you must migrate to the new options object structure to avoid type/runtime issues. This allows configuration of advanced features like deleteCacheKeys directly inside the middleware.
import { createClient } from 'redis';
import { TagCache, TagcacheMiddleware } from 'express-tag-cache';
const redisClient = createClient();
await redisClient.connect();
const tagcache = new TagCache({
redis: redisClient,
tagPrefix: 'tag:',
cachePrefix: 'cache:',
tagTtl: 240, // Default TTL for tag sets in seconds
cacheTtl: 180, // Default TTL for cache entries in seconds
appContext: 'my-app',
sizeGuard: 2048, // Maximum allowed cache entry size in KB (e.g., 2MB)
deleteCacheKeys: true, // Default behavior for tag invalidations (true = hard, false = soft)
tagIndexMaintenanceMode: 'strict' // Tag index pruning strategy ('strict' = active pruning on reads/invalidations, 'lazy' = skip active pruning for better performance)
});
const cacheMiddleware = new TagcacheMiddleware({
tagcache,
enable: true // Set to false to globally bypass cache/invalidation middleware
});You can cache routes using static tags, dynamic tags (resolved using functions that take the Express Request object), or a combination of both:
import express from 'express';
const app = express();
// Cache list of products (static tags)
app.get('/api/products',
cacheMiddleware.cache(['products', 'list']),
async (req, res) => {
// Data fetching logic...
res.json({ products: [] });
}
);
// Cache a single product dynamically based on request params
app.get('/api/products/:id',
cacheMiddleware.cache([
'products',
(req) => `products:${req.params.id}`
]),
async (req, res) => {
// Data fetching logic...
res.json({ id: req.params.id, name: 'Product Details' });
}
);Invalidate caches automatically when mutating data:
// Invalidate list cache on creation
app.post('/api/products',
cacheMiddleware.invalidate(['products']),
async (req, res) => {
// Product creation logic...
res.json({ success: true });
}
);
// Invalidate both list and specific item caches on update
app.put('/api/products/:id',
cacheMiddleware.invalidate([
'products',
(req) => `products:${req.params.id}`
]),
async (req, res) => {
// Product update logic...
res.json({ success: true });
}
);// Set cache manually
await tagcache.set({
key: 'user:123',
value: JSON.stringify(userData),
tags: ['users', 'user:123'],
cacheTtl: 3600, // Override default cacheTtl in seconds
tagTtl: 7200 // Override default tagTtl in seconds
});
// Get cache manually
const cachedData = await tagcache.get({
key: 'user:123',
tags: ['users'] // Will return null if any associated tag was invalidated
});
// Invalidate tags manually
await tagcache.invalidate({
tags: ['user:123'],
deleteCacheKeys: true // Set to false for soft invalidation (tag set deletion only)
});When multiple services share the same Redis instance, you can read and invalidate caches across service boundaries by passing an appContext override to any method.
// ── Service A: "user-service" ──
const userCache = new TagCache({
redis: redisClient,
appContext: 'user-service'
});
// ── Service B: "order-service" ──
const orderCache = new TagCache({
redis: redisClient,
appContext: 'order-service'
});
// Read a cache entry that belongs to user-service from order-service
const userData = await orderCache.get({
key: 'user:123',
tags: ['users'],
appContext: 'user-service' // Read from user-service's cache namespace
});
// Invalidate user-service's cache from order-service
await orderCache.invalidate({
tags: ['users'],
appContext: 'user-service' // Target user-service's tags
});const orderMiddleware = new TagcacheMiddleware({
tagcache: orderCache,
enable: true
});
// When an order is placed, invalidate user-service's "users" tag and delete actual cache keys
app.post('/api/orders',
orderMiddleware.invalidate(['users'], { appContext: 'user-service', deleteCacheKeys: true }),
async (req, res) => {
// Order creation logic...
res.json({ success: true });
}
);Note: When
appContextanddeleteCacheKeysare omitted, they fall back to the instance's defaults (withdeleteCacheKeysdefaulting tofalsefor soft invalidation).
The core caching class that manages Redis interactions, tag association, and invalidations.
Initializes a new TagCache instance.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
redis |
RedisClientType |
Yes | — | An active, connected Redis client instance (compatible with the redis npm package). |
tagPrefix |
string |
No | 'tagcache:tag:' |
Prefix prepended to all tag set keys in Redis. |
cachePrefix |
string |
No | 'tagcache:data:' |
Prefix prepended to all cache value keys in Redis. |
tagTtl |
number |
No | 240 |
Default time-to-live (TTL) in seconds for tag set keys. Must be >= 0. |
cacheTtl |
number |
No | 180 |
Default time-to-live (TTL) in seconds for cached value keys. Must be >= 0. |
appContext |
string |
No | 'tagcache' |
A namespace prefix applied before all keys, providing isolation for multi-tenant applications. |
sizeGuard |
number |
No | 2048 |
Maximum cache entry size in KB. Cache attempts exceeding this limit are skipped to prevent Redis performance degradation. Must be > 0. |
deleteCacheKeys |
boolean |
No | true |
Default strategy for invalidations. If true, both tag sets and cached data keys are deleted (hard invalidation). If false, only tag sets are deleted (soft invalidation). |
tagIndexMaintenanceMode |
'strict' | 'lazy' |
No | 'strict' |
Maintenance strategy for pruning expired cache keys from tag indexes. In 'strict' mode, expired keys are actively pruned using ZREMRANGEBYSCORE on every read and invalidation operation, ensuring strict tag validity but introducing extra Redis write commands. In 'lazy' mode, expired keys are kept in the tag index and bypassed dynamically, offering higher performance and less Redis CPU overhead. |
Fetches a cached value by its key. The cache entry is only returned if it is still valid and has not been invalidated by any of its associated tags.
const value = await tagcache.get({
key: 'user:profile:123',
tags: ['users', 'user:123']
});
// Cross-service: read from another service's cache
const crossValue = await tagcache.get({
key: 'user:profile:123',
tags: ['users'],
appContext: 'other-service'
});Parameters (CacheFetchOptions):
key(string): The original, un-prefixed cache key.tags(string[]): An array of tags associated with this cache key. The method verifies if the cache key is still present in the Redis set for every tag. If the key has been removed from any tag set (invalidated), this method returnsnull.appContext(string, optional): Override the instance'sappContextto read from a different service's cache namespace.
Returns: Promise<string | null> — The cached string value if valid, or null if expired, invalidated, or not found.
Stores a value in the cache, associates it with a list of tags, and configures the TTLs.
await tagcache.set({
key: 'user:profile:123',
value: JSON.stringify(userProfile),
tags: ['users', 'user:123'],
cacheTtl: 300,
tagTtl: 600
});Parameters (CacheStoreOptions):
key(string): The original, un-prefixed cache key.value(string): The string value to cache (usually serialized JSON).tags(string[]): An array of tags to associate with this cached item.cacheTtl(number, optional): TTL in seconds for this cache entry. Falls back to the instance's defaultcacheTtlif not specified.tagTtl(number, optional): TTL in seconds for the tag set keys. Falls back to the instance's defaulttagTtlif not specified.appContext(string, optional): Override the instance'sappContextto write into a different service's cache namespace.
Returns: Promise<boolean> — true if the cache was successfully set, false otherwise.
Invalidates all cached items associated with the specified tags.
await tagcache.invalidate({
tags: ['user:123'],
deleteCacheKeys: true
});Parameters (CacheInvalidationOptions):
tags(string[]): An array of tags to invalidate.deleteCacheKeys(boolean, optional):false(Default - Soft Invalidation): Deletes only the tag sets in Redis. Cached entries remain in Redis but become unreachable viaget()because tag-membership verification fails. They will naturally expire based on their TTL. This is highly performant because it avoids deleting many individual keys.true(Hard Invalidation): Retrieves all cache keys associated with the tags and deletes both the tag sets and the actual cached data keys from Redis immediately.
appContext(string, optional): Override the instance'sappContextto invalidate tags in a different service's cache namespace.
Returns: Promise<boolean> — true if invalidation succeeded, false otherwise.
Checks if a cache key is currently associated with all specified tags in Redis.
const isValid = await tagcache.isMember({
key: 'user:profile:123',
tags: ['users', 'user:123']
});Parameters (IsMemberOptions):
key(string): The original, un-prefixed cache key.tags(string[]): An array of tags to check against.appContext(string, optional): Override the instance'sappContextto check membership in a different service's cache namespace.
Returns: Promise<boolean> — true if the key is a member of all the specified tag sets, false otherwise.
Explicitly and immediately deletes a cache key from Redis.
await tagcache.delete({ key: 'user:profile:123' });Parameters (CacheDeleteOptions):
key(string): The original, un-prefixed cache key.appContext(string, optional): Override the instance'sappContextto delete a key in a different service's cache namespace.
Returns: Promise<boolean> — true if deletion was successful, false otherwise.
An Express middleware wrapper for TagCache that enables automatic caching and invalidation of HTTP responses.
Initializes a new TagcacheMiddleware instance.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
tagcache |
TagCache |
Yes | — | An instance of the TagCache class. |
enable |
boolean |
Yes | — | Toggles the middleware behavior. If false, all cache hits are bypassed, and invalidations are skipped. |
Express middleware that automatically serves cached responses or captures and caches incoming successful JSON responses.
app.get('/api/users/:id',
cacheMiddleware.cache([
'users',
(req) => `user:${req.params.id}`
]),
(req, res) => { ... }
);Parameters:
tags(TagInput[]): An array of static tags (string) or dynamic tag resolvers ((req: Request) => string).
Key Features & Cache Key Generation:
- Cache Key: A deterministic SHA-256 hash is generated using:
- The request HTTP method (e.g.,
GET). - The request path (with trailing slashes normalized).
- A sorted representation of
req.params,req.query, andreq.body(body is included only forPOST,PUT, andPATCHrequests).
- The request HTTP method (e.g.,
- HTTP Headers set:
X-Cache: HIT: Served directly from the cache.X-Cache-Key: The formatted cache key used in Redis (provided on cache hits).X-Cache: MISS: The response was not cached; the interceptor will attempt to cache the response once the request completes.X-Cache: BYPASS: Caching was bypassed due to configuration or headers.
- Automatic Bypass: Cache is bypassed if:
enableis set tofalse.- The request contains
Cache-Control: no-cacheorCache-Control: no-storeheaders. - The request contains
Pragma: no-cacheheader.
- Caching Criteria: Only responses meeting the following are cached:
- HTTP status code is successful (
200to299). Content-Typeheader includesapplication/json.- The serialized body size does not exceed the configured
sizeGuardlimit.
- HTTP status code is successful (
Express middleware that attaches a listener to invalidate the specified tags when a mutation response is successfully sent.
// Invalidate own service's tags (uses default soft invalidation)
app.put('/api/users/:id',
cacheMiddleware.invalidate([
'users',
(req) => `user:${req.params.id}`
]),
(req, res) => { ... }
);
// Cross-service: invalidate another service's tags and delete keys (hard invalidation)
app.post('/api/orders',
cacheMiddleware.invalidate(['users'], { appContext: 'user-service', deleteCacheKeys: true }),
(req, res) => { ... }
);Parameters (TagcacheMiddlewareInvalidateOpts):
appContext(string, optional): Override the instance'sappContextto target tags belonging to a different service's cache namespace.deleteCacheKeys(boolean, optional):false(Default - Soft Invalidation): Deletes only the tag sets in Redis. Cached entries remain but become unreachable viaget(), naturally expiring. Highly performant.true(Hard Invalidation): Retrieves all cache keys associated with the tags and deletes both the tag sets and the actual cached data keys from Redis immediately.
Behavior:
- Resolves the tags dynamically from the request.
- Listens to the
finishevent of the response. - If the response HTTP status code is successful (
200to304), it automatically invalidates all resolved tags by callingtagcache.invalidate({ tags, deleteCacheKeys, appContext }).
MIT