We use cookies to enhance your experience on the site
CodeWorlds

Caching - supply lockers in an application

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:

  1. Check the cache (
    get
    ).
  2. If there is an entry - return it to the client.
  3. If not (a miss) - fetch from the database.
  4. 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:

  1. Expiration (TTL) - the entry disappears by itself after a while. You write nothing, but you show stale data for a moment.
  2. Manual - you remove a specific key when the data changes. Precise, but you must remember every place that writes.
  3. 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, @name. 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
    ;
    max
    caps 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
    ;
    @CacheKey
    names the entry,
    @UseInterceptors
    switches 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.

Go to CodeWorlds