The list of provinces changes once a quarter. The request for it arrives a thousand times a minute - and each time the database does the same work to return the same result. The server is loaded not because it does a lot, but because it keeps doing the same thing.
A legion did not send a runner to Rome for every loaf of bread. It kept a supply locker by the camp: what was needed often lay to hand, and for the rest one went down to the depot. A cache is such a locker - the temporary storage of data for faster access.
Note the word "temporary". A cache is not permanent storage - the database is for that. Nor is it a compression tool or a logging tool. The data in it is meant to disappear, and that is a feature, not a fault.
configures the cache along with its parameters - not routing, not authorisation, not logging:CacheModule.register()
1@Module({
2 imports: [
3 CacheModule.register({
4 isGlobal: true,
5 ttl: 300,
6 max: 1000,
7 }),
8 ],
9})
10export class AppModule {}
(Time To Live) means an entry's maximum lifetime, after which it is removed automatically - 300 seconds here. It is not a number of permitted reads nor a response time; it is an expiry date.ttl
caps the number of entries. Once exceeded, the cache drops the least recently used - because memory is finite, and a cache without a limit will exhaust it sooner or later.max
We inject the cache by token. The written order:
, @Inject(CACHE_MANAGER)
, private cache:
:Cache
1@Injectable()
2export class ProvinceService {
3 constructor(
4 @Inject(CACHE_MANAGER) private cache: Cache,
5 private repo: ProvinceRepository,
6 ) {}
7
8 async findAll() {
9 const cached = await this.cache.get<Province[]>('provinces:all');
10 if (cached) {
11 return cached;
12 }
13
14 const provinces = await this.repo.findAll();
15 await this.cache.set('provinces:all', provinces, 600);
16
17 return provinces;
18 }
19}This pattern is called cache-aside (or lazy loading) and means the code fetches from the cache manually and writes to it manually. Four steps, always in this order:
get).set) and return it.The name "lazy" comes from step three: the cache fills lazily, only once somebody asks for the data. The first request always goes to the database.
For ordinary reads you need not write those four steps:
1@Controller('provinces')
2export class ProvinceController {
3 @Get()
4 @UseInterceptors(CacheInterceptor)
5 @CacheKey('provinces:all')
6 @CacheTTL(600)
7 findAll() {
8 return this.provinceService.findAll();
9 }
10}
automatically caches the endpoint's responses - it does not compress, log or validate them.@UseInterceptors(CacheInterceptor)
is the decorator responsible for the lifetime of this method's data - six hundred seconds, ten minutes, regardless of the default. Do not confuse it with @CacheTTL(600)
@CacheKey, which gives the entry a name, nor with @UseInterceptors, which merely switches caching on.By default the key comes from the URL.
@CacheKey is useful when you want to set it yourself - for instance so you can later remove that same entry from a service.The approaches above fill the cache on a read. You can also fill it on a write, and here two variants differ by a single word:
The difference comes down to whether the application waits for the database. Choose write-through where losing a write is unacceptable; write-behind where throughput matters and one lost entry is no disaster - as with view counters.
Data in a cache grows stale. The ways of dealing with that run from simplest to most elaborate:
provinces:. The most powerful, and the easiest to remove too much with.The invalidation process after an update has three steps: update the data in the database, identify and remove the relevant keys, and the next request fetches fresh data and stores it anew.
With cache-aside the first user after every restart pays the full price of a database read. You can prevent that by filling the locker before anyone asks:
1@Injectable()
2export class CacheWarmupService implements OnModuleInit {
3 constructor(
4 @Inject(CACHE_MANAGER) private cache: Cache,
5 private repo: ProvinceRepository,
6 ) {}
7
8 async onModuleInit() {
9 const provinces = await this.repo.findAll();
10 await this.cache.set('provinces:all', provinces, 3600);
11 }
12}
is a method NestJS calls at application startup, once the module is built. The process has three stages: identify the critical data, fetch and store it at startup, adjust the list based on what actually gets queried most.onModuleInit
Warm only what is both frequently needed and rarely changed, @name. Warming everything lengthens startup and fills memory with data nobody will reach for.
The locker by the camp works, and for the rest one goes to the depot:
CacheModule.register() configures the cache with its parameters; max caps the number of entries,@Inject(CACHE_MANAGER), private cache:, Cache,@UseInterceptors(CacheInterceptor) automatically caches the endpoint's responses,@CacheTTL(600) sets the lifetime; @CacheKey names the entry, @UseInterceptors switches the mechanism on,onModuleInit: identify the critical data → fetch and store at startup → adjust the list.In the next lesson we will move the locker outside the process - into Redis, so that every application instance sees it. For now remember: a cache does not speed work up, it lets you not do it - and the whole difficulty lies in knowing when the supplies have gone stale.