NestJS course Β· Module 8: Caching and Performance

Redis Integration - fast storehouses for tributes

5 min read
In this lesson5

You met in-process caching in the previous lesson and it works beautifully - as long as you have one server. Start a second instance behind a load balancer and each builds its own cache. A user lands on one, then the other, and gets different data. Worse: invalidating an entry on the first instance does not remove it from the second.

A legion did not keep supplies in every soldier's pack. It had a shared storehouse everyone drew from. Redis is such a storehouse - and that is its main advantage over an in-memory cache: sharing the cache between many application instances.

It is not that it is always faster - an in-process cache is often faster, because it needs no network round trip. It is that every instance sees the same thing.

Four uses

Redis in NestJS serves four purposes at once: cache, sessions, pub/sub and queue.

Cache is where people usually start. Sessions - the user sessions you met with authentication; thanks to a shared store, logging in on one instance holds on all of them. Pub/sub - broadcasting events between services. Queue - the job queues we returned to with the Empire's couriers.

That is why Redis appears in almost every stack: one service covers four needs that would otherwise take four separate tools.

Four configuration steps

Wiring Redis up as the cache store always goes the same way:

  1. Install the packages: cache-manager-redis-store.
  2. Configure CacheModule with the Redis store.
  3. Inject CACHE_MANAGER in the service.
  4. Use cache.get() and cache.set() with keys.

Step two looks like this:

1@Module({
2  imports: [
3    CacheModule.registerAsync({
4      isGlobal: true,
5      useFactory: (config: ConfigService) => ({
6        store: redisStore,
7        host: config.get('REDIS_HOST'),
8        port: config.get('REDIS_PORT'),
9        ttl: 300,
10      }),
11      inject: [ConfigService],
12    }),
13  ],
14})
15export class AppModule {}

Note one field: store. It is what turns the default in-memory cache into Redis - the rest of the configuration merely says where that Redis lives. The whole application uses the same interface; swapping the store touches not one line in the services.

ttl: 300 is the default lifetime of an entry in seconds - five minutes. After that Redis removes it by itself.

Use in a service

Steps three and four are ordinary code:

1@Injectable()
2export class LegionService {
3  constructor(
4    @Inject(CACHE_MANAGER) private cache: Cache,
5    private repo: LegionRepository,
6  ) {}
7
8  async findOne(id: number) {
9    const key = `legion:${id}`;
10
11    const cached = await this.cache.get<Legion>(key);
12    if (cached) {
13      return cached;
14    }
15
16    const legion = await this.repo.findOne(id);
17    await this.cache.set(key, legion, 600);
18
19    return legion;
20  }
21}

@Inject(CACHE_MANAGER) is injection by token - the same mechanism you met with providers that have no class type.

The pattern itself is called cache-aside: ask the cache first, return immediately on a hit, and on a miss go to the database and only then store the result. The third argument to set overrides the default TTL for that single entry.

It is worth building keys with a prefix, like legion:42. Without one, sooner or later two different entities will start using the same number as a key, and the bug will surface at the worst possible moment.

Write-through - the other strategy

Cache-aside writes to the cache only after a read from the database. There is an opposite approach, write-through, in which the cache is written on every data write rather than on a read. The order here is fixed and worth knowing:

  1. The application sends a write.
  2. The cache stores the data.
  3. The cache synchronously writes to the database.
  4. A confirmation is returned to the application.

The key word is synchronously in step three: the application waits until the data reaches both the cache and the database. Thanks to that the cache is never stale - but a write takes longer than a write to the database alone.

Choosing between the strategies comes down to what you need more. Cache-aside is cheaper on writes and suffices when data changes rarely or brief staleness does no harm. Write-through costs on every write but guarantees consistency - and it earns its keep where a stale read means a real problem, as with a balance or stock level.

Summary

The shared storehouse stands and every instance draws from the same one:

  • Redis's main advantage over an in-memory cache is sharing the cache between many application instances - not that it is always faster,
  • Redis in NestJS serves four purposes: cache, sessions, pub/sub and queue,
  • configuration in four steps: install cache-manager-redis-store β†’ configure CacheModule with the Redis store β†’ inject CACHE_MANAGER β†’ use cache.get() and cache.set(),
  • the store field turns the default in-memory cache into Redis; the services do not change at all,
  • ttl is an entry's lifetime in seconds, and set's third argument overrides it for one key,
  • cache-aside: ask the cache, on a miss go to the database and only then store the result,
  • build keys with a prefix (legion:42) so two entities never start using the same number,
  • write-through in order: the application sends a write β†’ the cache stores the data β†’ the cache synchronously writes to the database β†’ a confirmation returns to the application,
  • cache-aside is cheaper on writes, write-through guarantees consistency - choose by whether a stale read can do harm.

In the next lesson we will turn to optimising the application itself - what speeds it up before you even think about a cache. For now remember: an in-memory cache belongs to a process, Redis belongs to everyone - and only that makes the second instance see what the first one sees.

Code for this lesson: src/redis-integration.ts
1// Redis Integration - Fast Tribute Stores
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. Configuring Redis as the cache store
8// npm install cache-manager-redis-store redis
9@Module({
10  imports: [
11    CacheModule.register({
12      // store: redisStore,
13      // host: process.env.REDIS_HOST || 'localhost',
14      // port: parseInt(process.env.REDIS_PORT) || 6379,
15      // password: process.env.REDIS_PASSWORD,
16      ttl: 300,
17      max: 1000,
18      isGlobal: true,
19    }),
20  ],
21})
22export class RedisCacheModule {}
23
24// 2. Async configuration with ConfigService
25// CacheModule.registerAsync({
26//   imports: [ConfigModule],
27//   inject: [ConfigService],
28//   useFactory: (config: ConfigService) => ({
29//     store: redisStore,
30//     host: config.get('REDIS_HOST'),
31//     port: config.get('REDIS_PORT'),
32//     ttl: config.get('CACHE_TTL'),
33//   }),
34// })
35
36// 3. Advanced Redis service
37@Injectable()
38export class RedisCacheService {
39  constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
40
41  // Cache-Aside Pattern
42  async getOrSet<T>(
43    key: string,
44    fetcher: () => Promise<T>,
45    ttl: number = 300
46  ): Promise<T> {
47    let data = await this.cache.get<T>(key);
48    if (data) return data;
49
50    data = await fetcher();
51    await this.cache.set(key, data, ttl * 1000);
52    return data;
53  }
54
55  // Write-Through Pattern
56  async writeThrough<T>(
57    key: string,
58    data: T,
59    saveFn: (data: T) => Promise<void>,
60    ttl: number = 300
61  ): Promise<void> {
62    await saveFn(data);         // Save to database
63    await this.cache.set(key, data, ttl * 1000); // Save to cache
64  }
65
66  // Cache Warming - warming up the cache
67  async warmup(
68    keys: string[],
69    fetcher: (key: string) => Promise<any>
70  ) {
71    const promises = keys.map(async (key) => {
72      const data = await fetcher(key);
73      await this.cache.set(key, data, 600000); // 10 minutes
74    });
75    await Promise.all(promises);
76    console.log(`Cache warmed up: ${keys.length} keys`);
77  }
78
79  // Invalidation by pattern
80  async invalidateByPrefix(prefix: string) {
81    // In Redis: KEYS prefix:* -> DEL
82    console.log(`Invalidating keys: ${prefix}:*`);
83    // In a real implementation with Redis:
84    // const keys = await redis.keys(`${prefix}:*`);
85    // await Promise.all(keys.map(k => this.cache.del(k)));
86  }
87}
88
89// Redis supports additional structures:
90// - Strings (cache)
91// - Lists (queues)
92// - Sets (unique collections)
93// - Sorted Sets (rankings)
94// - Hashes (objects)
95// - Pub/Sub (communication)
96

Spotted 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. 1. Redis in NestJS can be used for:

  2. 2. The main advantage of Redis over in-memory cache is:

Hands-on tasks in the game

  • Code editor

    Connect Redis as the cache backend in CacheModule

  • Vertical ordering

    Arrange the steps for configuring Redis cache in NestJS:

  • Vertical ordering

    Arrange the steps of the Write-Through cache strategy:

Useful articles