NestJS course Β· Module 8: Caching and Performance
Caching - supply lockers in an application
In this lesson7
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.
Configuration
CacheModule.register() configures the cache along with its parameters - not routing, not authorisation, not logging:
1@Module({
2 imports: [
3 CacheModule.register({
4 isGlobal: true,
5 ttl: 300,
6 max: 1000,
7 }),
8 ],
9})
10export class AppModule {}ttl (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.
max 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.
Manual read and write
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:
- Check the cache (
get). - If there is an entry - return it to the client.
- If not (a miss) - fetch from the database.
- Store the result in the cache (
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.
Automatically, through decorators
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}@UseInterceptors(CacheInterceptor) automatically caches the endpoint's responses - it does not compress, log or validate them.
@CacheTTL(600) 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 @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.
Writing - two strategies
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:
- Write-through writes to the cache and the database synchronously - the application waits for both. The cache is never stale, but a write takes longer.
- Write-behind writes to the cache immediately and to the database asynchronously, later. The write is instant, but a failure between the two can lose the data.
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.
Invalidation - three strategies
Data in a cache grows stale. The ways of dealing with that run from simplest to most elaborate:
- Expiration (TTL) - the entry disappears by itself after a while. You write nothing, but you show stale data for a moment.
- Manual - you remove a specific key when the data changes. Precise, but you must remember every place that writes.
- Pattern-based - you remove groups of keys matching a pattern, say everything beginning with
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.
Warming the cache
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}onModuleInit 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.
Warm only what is both frequently needed and rarely changed. Warming everything lengthens startup and fills memory with data nobody will reach for.
Summary
The locker by the camp works, and for the rest one goes to the depot:
- a cache is the temporary storage of data for faster access - not permanent storage, not compression, not logging,
CacheModule.register()configures the cache with its parameters;maxcaps the number of entries,- TTL means an entry's maximum lifetime, after which it is removed automatically,
- injection in order:
@Inject(CACHE_MANAGER),private cache:,Cache, - cache-aside means fetching and writing manually; four steps: check the cache β return on a hit β fetch from the database on a miss β store and return,
@UseInterceptors(CacheInterceptor)automatically caches the endpoint's responses,@CacheTTL(600)sets the lifetime;@CacheKeynames the entry,@UseInterceptorsswitches the mechanism on,- write-through writes to the database synchronously, write-behind asynchronously, risking loss on a failure,
- invalidation strategies from simplest: expiration (TTL) β manual (one key) β pattern-based (groups of keys),
- the process after an update: update the database β identify and remove the keys β the next request fetches fresh data,
- cache warming through
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.
Code for this lesson: src/caching-intro.ts
1// Caching - Supply Vaults in the Roman Empire
2import { Injectable, Inject, Module } from '@nestjs/common';
3import { CACHE_MANAGER } from '@nestjs/cache-manager';
4import { CacheModule } from '@nestjs/cache-manager';
5import { Cache } from 'cache-manager';
6
7// 1. CacheModule configuration in NestJS
8@Module({
9 imports: [
10 CacheModule.register({
11 ttl: 300, // Time To Live: 300 seconds (5 minutes)
12 max: 100, // Maximum 100 elements in cache
13 isGlobal: true // Available globally across the whole app
14 }),
15 ],
16})
17export class AppModule {}
18
19// 2. Service with caching
20@Injectable()
21export class TributeCacheService {
22 constructor(
23 @Inject(CACHE_MANAGER) private cacheManager: Cache
24 ) {}
25
26 // GET - Fetch from cache or from the database
27 async getTribute(id: number) {
28 const cacheKey = `tribute:${id}`;
29
30 // Check cache
31 let tribute = await this.cacheManager.get(cacheKey);
32 if (tribute) {
33 console.log('CACHE HIT - data from the vault!');
34 return tribute;
35 }
36
37 // CACHE MISS - fetch from the database
38 console.log('CACHE MISS - going to the archive...');
39 tribute = await this.fetchFromDatabase(id);
40
41 // Save to cache for 5 minutes
42 if (tribute) {
43 await this.cacheManager.set(cacheKey, tribute, 300000);
44 }
45 return tribute;
46 }
47
48 // SET - Save to cache
49 async cacheTribute(id: number, data: any, ttl = 300000) {
50 const key = `tribute:${id}`;
51 await this.cacheManager.set(key, data, ttl);
52 console.log(`Tribute ${id} saved in the vault for ${ttl/1000}s`);
53 }
54
55 // DELETE - Remove from cache (invalidation)
56 async invalidate(id: number) {
57 await this.cacheManager.del(`tribute:${id}`);
58 console.log(`Tribute ${id} removed from the vault`);
59 }
60
61 // CLEAR - Clear the entire cache
62 async clearAll() {
63 await this.cacheManager.reset();
64 console.log('All caches cleared!');
65 }
66
67 private async fetchFromDatabase(id: number) {
68 // Simulating database query
69 const tributes: Record<number, any> = {
70 1: { id: 1, province: 'Gallia', amount: 1000, type: 'gold' },
71 2: { id: 2, province: 'Aegyptus', amount: 2000, type: 'silver' },
72 };
73 return tributes[id] || null;
74 }
75}
76
77// Caching patterns:
78// - Cache-Aside (Lazy): check cache -> if missing -> fetch -> save
79// - Write-Through: Save to DB and cache simultaneously
80// - Write-Behind: Save to cache, later sync with DB
81// - Cache Warming: Pre-fill the cache upfront with popular data
82Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. Cache in a NestJS application is:
2. CacheModule.register() in NestJS configures:
These are 2 of 8 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Build a cache service with get and set methods for tributes
- Click in order
Arrange the CACHE_MANAGER injection into a service:
- Vertical ordering
Arrange the steps of the Cache-Aside (Lazy Loading) pattern:
- Code editor
Apply CacheInterceptor and CacheTTL on the GET /provinces endpoint
- Click in order
Arrange the cache invalidation process after a data update:
- Code editor
Write an update method that clears the appropriate cache key after data changes
- Vertical ordering
Arrange invalidation strategies from simplest to most complex:
- Click in order
Arrange the cache warming process (pre-loading cache on startup):