InteractiveFrameworks

Caching

Compute once, serve many times: CacheModule with a default lifetime, a route cached automatically with CacheInterceptor and invalidated on writes, and a cache-aside lookup by hand with the cache manager.

What you'll learn

  • Register CacheModule with a default ttl and explain what is stored and where
  • Cache a GET route with CacheInterceptor, choose its key with @CacheKey, and invalidate it on a write
  • Cache by hand with CACHE_MANAGER: get, set with a ttl, del and clear

The shelter's statistics page runs a full scan of the cats table every time anyone opens it, and the page is open on a screen in the lobby refreshing every few seconds. The numbers change a few times a day. Computing them a thousand times a day for the same answer is waste, and under load it is an outage. A cache keeps a computed answer for a while and hands it out again, and the whole craft is in "a while": how long, keyed by what, and thrown away when. Nest ships a cache module that does the mechanical part two ways, automatically for a route and by hand for anything else.

CacheModule

@nestjs/cache-manager wraps the cache-manager library, which in turn stores through Keyv, a key-value abstraction with an in-memory store by default and Redis and others a package away:

@Module({
  imports: [CacheModule.register()],
  controllers: [AppController],
})
export class AppModule {}

register({ ttl: 5000 }) sets the default time to live in milliseconds, after which an entry is dropped; 0 means never. isGlobal: true makes the cache available everywhere, and registerAsync builds the options from configuration. Switching to Redis is a stores option, not a code change, which is the reason to go through the module rather than a Map.

Caching a route automatically

CacheInterceptor caches a GET handler's response by the request URL and serves it from the cache until it expires:

@Controller()
@UseInterceptors(CacheInterceptor)
export class AppController {
  @Get()
  findAll(): string[] {
    return [];
  }
}

Only GET is cached; a POST is a change and goes through. Two decorators tune a route: @CacheKey('custom_key') replaces the URL as the key, and @CacheTTL(2000) overrides the module's default for that route, in milliseconds. Registered under APP_INTERCEPTOR the interceptor covers every GET in the application, which is more than most APIs want; per route is the usual choice. A subclass can override trackBy() to compute keys from headers or the user.

Caching by hand

The interceptor is a blunt instrument: the whole response, keyed by URL. For anything finer, inject the cache manager and use it:

constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}

const value = await this.cacheManager.get('key');   // undefined when absent
await this.cacheManager.set('key', 'value', 1000);  // ttl in milliseconds; 0 never expires
await this.cacheManager.del('key');
await this.cacheManager.clear();

The pattern is called cache-aside: look the key up, and on a miss compute, store, return. It costs four lines and gives you the key, the lifetime and the moment of invalidation.

Invalidation

A cache is a promise to serve old data, so the question is not whether the data will be stale but for how long, and who fixes it. The ttl bounds the staleness. Explicit invalidation ends it early: when something changes the underlying data, delete the entries it affects. With the interceptor that needs a key you chose with @CacheKey, because you cannot otherwise know what URL to delete. Deleting too much is safe and cheap; deleting too little is a bug report.

Your task

The statistics and each cat's facts are computed once and served many times.

  1. CatsModule registers a cache with a default time to live of one minute.
  2. GET /cats/stats is cached automatically under the key cats-stats; POST /cats drops that entry, since a new cat changes the statistics.
  3. GET /cats/:id/facts caches by hand under facts:<id> for ten seconds.
  4. DELETE /cats/cache clears everything.

Each response says how many times it was computed. Run the starter and watch the number climb with every request; then run the solution and watch it stay put until something changes.

When it fails

  • Nest can't resolve dependencies of the CatsController (?, CatsService), argument CACHE_MANAGER: CacheModule is not imported by the module that injects it.
  • computed climbs on every request to /cats/stats: the interceptor is missing, or the handler is not a GET.
  • After POST /cats the statistics are stale: the entry was deleted under a different key than the one the interceptor used; without @CacheKey the key is the URL.
  • facts is recomputed every time: the miss path stores under one key and the lookup reads another, or set is never awaited before returning.

Remember

  • CacheModule.register({ ttl }) provides the cache; ttl is in milliseconds, 0 is forever.
  • @UseInterceptors(CacheInterceptor) caches a GET by URL; @CacheKey and @CacheTTL tune it.
  • @Inject(CACHE_MANAGER) cache: Cache for get, set(key, value, ttl), del and clear, the cache-aside pattern.
  • Invalidate on writes, under the key you chose.
Stuck? Show a hint

Module: imports: [..., CacheModule.register({ ttl: 60_000 })] from '@nestjs/cache-manager'. Controller: @Inject(CACHE_MANAGER) private readonly cache: Cache (type from 'cache-manager'); @UseInterceptors(CacheInterceptor) @CacheKey('cats-stats') on stats(); await this.cache.del('cats-stats') after creating; facts: const cached = await this.cache.get<Facts>(key); if (cached) return cached; ... await this.cache.set(key, facts, 10_000); clear: await this.cache.clear().