Month 5, Week 1

Caching Strategies for High Performance

Architecting for Speed and Scale

Module 1: Why Caching is Critical

The Foundation of Performance

The Problem: The Database is Slow

Relative to your application's memory, your database is slow. Every query involves:

  1. A network round-trip to the database server.
  2. The database parsing the query and planning its execution.
  3. Reading data from disk (the slowest part).
  4. A network round-trip to send the data back.

If 1000 users all request the same popular product page, you are making 1000 identical, slow queries to your database.

The Solution: Caching

A cache is a high-speed data storage layer which stores a subset of data, transient in nature, so that future requests for that data are served up faster than is possible by accessing the data's primary storage location.

Analogy: The librarian's desk. Instead of walking to the back of the library (the database) for a popular book every time, the librarian keeps a copy on their desk (the cache). Subsequent requests are served instantly.

The Benefits of Caching

  • Reduced Latency: Retrieving data from an in-memory cache is orders of magnitude faster than from a disk-based database. This means a faster API and a better user experience.
  • Reduced Database Load: By serving popular requests from the cache, you dramatically reduce the number of queries hitting your database. This protects it from being overloaded and can significantly reduce your infrastructure costs.
  • Increased Throughput: Because requests are served faster, your application can handle a higher number of total requests per second.

Module 2: In-Memory Caching

The Simplest First Step

The Basic Idea

The simplest cache is one that lives inside your application's own memory. We can implement a basic version using a JavaScript `Map`.


                        // A very simple in-memory cache
                        const cache = new Map();

                        function get(key: string) {
                            return cache.get(key);
                        }

                        function set(key: string, value: any, ttl_seconds: number) {
                            cache.set(key, value);
                            // After a certain time (Time To Live), delete the key
                            setTimeout(() => {
                                cache.delete(key);
                            }, ttl_seconds * 1000);
                        }
                    

The Fatal Flaws of In-Memory Caching

While simple, this approach has two critical problems for production systems:

  1. Volatility: When your server restarts, the entire cache is wiped out. This can lead to a "thundering herd" problem where all requests suddenly hit your database at once.
  2. Not Scalable: If you run your application on multiple servers (scaling out), each server will have its own separate, inconsistent cache. A user's request might hit Server A (which has the data cached) and then their next request hits Server B (which does not).

In-memory caching is for single-instance applications or very specific use cases only.

Mid-Lecture Knowledge Check

Module 3: Redis - The Professional Solution

The Distributed In-Memory Datastore

What is Redis?

Remote Dictionary Server. It is an open-source, in-memory, key-value data store. It runs as a separate server that all of your application instances can connect to.

It solves both problems of the in-memory cache:

  • It is a centralized, shared cache for all your application servers.
  • It has optional persistence mechanisms, so data can survive a Redis restart.

Redis Data Types

Redis is more than just a simple key-value store. Its values can be complex data types.

  • Strings: The most common type.
  • Hashes: Maps between string fields and string values (like a mini-object).
  • Lists: A list of strings, in insertion order.
  • Sets: An unordered collection of unique strings.

Basic Redis Commands

You interact with Redis using simple commands.


                        # Set a key with a value
                        > SET user:1 '{"name": "Alex", "email": "alex@example.com"}'

                        # Get a value by its key
                        > GET user:1

                        # Set a key with an expiration of 60 seconds (Time To Live)
                        > SET user:2 '{"name": "Jane"}' EX 60

                        # Delete a key
                        > DEL user:1
                    

Module 4: Implementing Caching in NestJS

The `@nestjs/cache-manager`

The `cache-manager` Module

NestJS provides a unified caching module, `@nestjs/cache-manager`, which provides a standard API for caching and can be configured to use different storage engines.


                        npm install @nestjs/cache-manager cache-manager-redis-store
                    

Configuring the `CacheModule`

In your `AppModule`, you import and configure the `CacheModule` to use Redis.


                        import { Module } from '@nestjs/common';
                        import { CacheModule } from '@nestjs/cache-manager';
                        import * as redisStore from 'cache-manager-redis-store';

                        @Module({
                          imports: [
                            CacheModule.register({
                              isGlobal: true, // Make the cache available everywhere
                              store: redisStore,
                              host: 'localhost',
                              port: 6379,
                              ttl: 300, // Default time-to-live in seconds
                            }),
                          ],
                        })
                        export class AppModule {}
                     

Injecting and Using the Cache

You can then inject the cache manager into any service using the `@Inject(CACHE_MANAGER)` decorator.


                        import { Injectable, Inject } from '@nestjs/common';
                        import { CACHE_MANAGER } from '@nestjs/cache-manager';
                        import { Cache } from 'cache-manager';

                        @Injectable()
                        export class UsersService {
                          constructor(
                            @Inject(CACHE_MANAGER) private cacheManager: Cache,
                            // ... other dependencies
                          ) {}

                          async findOne(id: number): Promise {
                            const key = `user_${id}`;
                            
                            // Check the cache first
                            const cachedUser = await this.cacheManager.get(key);
                            if (cachedUser) {
                                console.log('Serving from cache!');
                                return cachedUser;
                            }

                            // If not in cache, get from DB
                            console.log('Serving from database...');
                            const user = await this.usersRepository.findOneBy({ id });
                            
                            // Store the result in the cache for next time
                            await this.cacheManager.set(key, user);

                            return user;
                          }
                        }
                     

Module 5: The Cache-Aside Pattern

The Most Common Caching Strategy

The Cache-Aside Flow

The code we just wrote is a perfect example of the "Cache-Aside" pattern. The application code is responsible for managing the cache.

  1. Application requests data.
  2. It first checks the **Cache**.
  3. **Cache Hit:** If data is found, return it to the application. Done.
  4. **Cache Miss:** If data is not found...
  5. Application queries the **Database**.
  6. Application stores the result in the **Cache**.
  7. Application returns the data.

Cache Invalidation: The Hardest Problem

What happens when a user's data is updated? The data in our cache is now stale and incorrect.

We must invalidate the cache. When we update or delete a user, we must also explicitly delete their entry from the cache.


                        async update(id: number, updateUserDto: UpdateUserDto): Promise {
                            // ... logic to update the user in the database ...
                            const updatedUser = await this.usersRepository.save(user);

                            const key = `user_${id}`;
                            // Invalidate (delete) the old data from the cache
                            await this.cacheManager.del(key);

                            return updatedUser;
                        }
                    

The next time this user is requested, it will be a cache miss, the app will fetch the fresh data from the database, and repopulate the cache.

In-Class Practical Exercise

Caching the `findAll` Endpoint

You will be given a NestJS project with a `CacheModule` already configured. Your task is to implement the cache-aside pattern for the `findAll()` method in the `UsersService`.

  1. Inject the `CACHE_MANAGER` into the `UsersService` constructor.
  2. In the `findAll()` method, define a unique cache key (e.g., `'all_users'`).
  3. First, try to get the data from the cache using `await this.cacheManager.get(key)`.
  4. If data is found (`cacheHit`), log a message and return the cached data.
  5. If there is a cache miss, log a message, fetch the users from the `usersRepository`, and store the result in the cache using `await this.cacheManager.set(key, users)`.
  6. Return the users fetched from the database.
  7. Bonus: In the `create` and `remove` methods, add logic to invalidate (delete) the `'all_users'` cache key, ensuring the list stays fresh.

Final Knowledge Check