Invalidation
A cached entry stops being wrong in three ways: it expires, you delete it on write, or you change the key.
await cache.del(key) | cache.wrap(key, fn, ttl?, refreshThreshold?)Caching is easy; deciding when the copy is no longer true is the work. There are exactly three mechanisms, and real systems use all three.
Let it expire
A TTL is an upper bound on staleness, in milliseconds. Set a default on the module, override it per set call, or per route with @CacheTTL(). Pick the number from how wrong the data may be, not from how slow the query is.
Delete on write
After a write succeeds, del the keys it affected. Order matters: write first, then delete, and for a transaction delete after the commit. A delete issued before the write lets a concurrent read reload the old row and cache it again. Prefer deleting over writing the new value, because two writers racing can leave the loser's value in the cache.
Change the key
There is no pattern delete. The API is del(key), mdel(keys) and clear(), so a whole family of keys is retired by putting a version number in them and bumping it; the orphaned entries fall out on their own TTL.
Stampedes
When a hot key expires, every concurrent request misses and all of them hit the database. wrap coalesces concurrent calls for the same key within one process, so one function runs and the rest await it — but that is per process, so eight replicas still make up to eight calls. refreshThreshold avoids the gap: once the remaining TTL falls below it, the stale value is served and refreshed in the background.
Example
// src/products/products.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Product } from './product.entity';
import { UpdateProductDto } from './dto/update-product.dto';
Injectable()
export class ProductsService {
constructor(
Inject(CACHE_MANAGER) private readonly cache: Cache,
InjectRepository(Product)
private readonly products: Repository<Product>,
) {}
// One place that builds the keys, so the write path cannot misspell them.
private keys(id: number): string[] {
return [`product:${id}`, `product:${id}:reviews`];
}
findOne(id: number): Promise<Product | null> {
return this.cache.wrap(
`product:${id}`,
() => this.products.findOneBy({ id }),
60_000, // ttl
10_000, // refreshThreshold: refresh in the background in the last 10s
);
}
async update(id: number, dto: UpdateProductDto): Promise<void> {
// 1. write
await this.products.update({ id }, dto);
// 2. then invalidate. mdel takes an array and removes each key.
// Deleting after the write means a read racing with us reloads the NEW row.
await this.cache.mdel(this.keys(id));
}
}When to use it
- Delete product:<id> after a price change so the next request sees the new price.
- Invalidate only after a multi-table transaction commits, not inside it.
- Retire every cached catalogue page after a bulk import without knowing the keys.
- Stop a nightly report query from being run by fifty requests at once when its key expires.
More examples
Invalidating after the commit
The invalidation belongs after the transaction resolves, never inside it. If the transaction rolls back, the delete never runs, which is correct: nothing changed, so the cached copy is still true.
// src/orders/orders.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager';
import { DataSource } from 'typeorm';
import { Order } from './order.entity';
import { Product } from '../products/product.entity';
@Injectable()
export class OrdersService {
constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly dataSource: DataSource,
) {}
async place(productId: number, quantity: number): Promise<string> {
const orderId = await this.dataSource.transaction(async (manager) => {
await manager.decrement(Product, { id: productId }, 'stock', quantity);
const order = await manager.save(Order, { productId, quantity });
return order.id;
// Do NOT delete cache keys in here. Until this callback returns the
// transaction is uncommitted, so a reader would still see the old stock
// and would cache it again.
});
// The commit has happened, so the next read loads the new row.
await this.cache.del(`product:${productId}`);
return orderId;
}
}Retiring a whole family of keys
Use this when one write invalidates an unknown number of entries, such as a re-import that changes every listing page. It costs one extra read per lookup, which you can avoid by caching the version in memory for a few seconds.
// src/catalog/catalog-cache.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager';
@Injectable()
export class CatalogCacheService {
private static readonly VERSION_KEY = 'catalog:version';
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
// The version itself must not expire, so it is stored with ttl 0.
private async version(): Promise<number> {
const current = await this.cache.get<number>(CatalogCacheService.VERSION_KEY);
if (current !== undefined) {
return current;
}
await this.cache.set(CatalogCacheService.VERSION_KEY, 1, 0);
return 1;
}
// Every catalog key carries the version: "catalog:v3:page:2".
async key(suffix: string): Promise<string> {
return `catalog:v${await this.version()}:${suffix}`;
}
// Bumping the version orphans every key built from the old one. They are
// unreachable immediately and are evicted later by their own TTL.
async invalidateAll(): Promise<void> {
const next = (await this.version()) + 1;
await this.cache.set(CatalogCacheService.VERSION_KEY, next, 0);
}
}Watching the background refresh
Background refresh hides expiry from callers, but it also means a failing origin can go unnoticed while stale values keep being served. The refresh event is where you notice. Only wrap() triggers it; a plain get() does not.
// src/cache/cache.module.ts
import { Inject, Logger, Module, OnModuleInit } from '@nestjs/common';
import { CACHE_MANAGER, CacheModule } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager';
@Module({
imports: [
CacheModule.register({
isGlobal: true,
ttl: 60_000,
// Applies to every wrap() call that does not pass its own value:
// in the last 10 seconds of an entry's life, serve it and refresh
// it in the background instead of making the caller wait.
refreshThreshold: 10_000,
}),
],
})
export class AppCacheModule implements OnModuleInit {
private readonly logger = new Logger(AppCacheModule.name);
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
onModuleInit(): void {
// cache-manager emits refresh when a background refresh completes.
this.cache.on('refresh', ({ key, error }) => {
if (error) {
this.logger.warn(`background refresh failed for ${key}`);
return;
}
this.logger.debug(`refreshed ${key}`);
});
}
}
Discussion