Backend with NestJS
Backend with NestJS

Caching & rate limits with Redis

Full · ~45 min
After this unit you can
  • Write cache-aside that sets a TTL, invalidates after the write, never caches a failed load, and collapses concurrent misses into one load
  • Configure CacheModule (cache-manager v7) with a Redis store, TTLs in milliseconds and undefined misses, and spot when CacheInterceptor's URL key serves one user's response to another
  • Configure @nestjs/throttler v6 and explain why per-process storage and an untrusted proxy each break the limit
  • Choose fail-open or fail-closed for a cache, a rate limit and sessions when Redis is down, and write the fallback

Read

Simple
1 / 2

Cache-aside — a copy that's allowed to be a little stale

Flag

A cache is a copy of data whose real home is somewhere slower — usually your database. The most common way to use one is cache-aside: your code checks the cache first and, on a miss, loads from the database and leaves a copy behind for next time.

async getProduct(id: string): Promise<Product> {
  const key = `product:${id}`;
  const cached = await this.cache.get<Product>(key);
  if (cached !== undefined) return cached;               // hit: no database call

  const product = await this.db.findProduct(id);         // miss: ask the source of truth
  await this.cache.set(key, product, 60_000);            // keep a copy for 60 seconds
  return product;
}

async updatePrice(id: string, price: number) {
  await this.db.updatePrice(id, price);                  // the database changes first
  await this.cache.del(`product:${id}`);                 // then the copy goes; the next read reloads
}

Three rules hold the pattern together:

  1. The database is the truth. The cache never holds anything the database doesn't.
  2. Every copy expires. The TTL (time to live) is how stale you're willing to be. It's also the safety net for changes that never pass through updatePrice — an admin script, another service, a migration.
  3. Every write path drops the copy. Otherwise readers see the old price until the TTL runs out.

Two details that bite on day one

TTLs are milliseconds. In cache-manager — the library behind Nest's CacheModule, currently v7 — set(key, value, 60) keeps the value for 60 milliseconds, so the "cache" almost never hits. One minute is 60_000.

A miss is undefined, so compare to undefined. if (cached) treats a cached 0, "" or false as a miss: a product with zero stock goes back to the database on every request. (cache-manager v6 returned null for a miss; v7 returns undefined. Code written for v6 that checks cached !== null treats every v7 miss as a hit and returns undefined to the caller.)

The challenge at the end of this unit makes this service safe for production traffic: TTLs, invalidation, a burst of misses, and a cache that's down.

Medium
1 / 2

CacheModule on Redis, and what CacheInterceptor keys on

Flag

Nest's caching lives in @nestjs/cache-manager (12.0 for Nest 12 — it jumped from 3.x to match the framework's major) on top of cache-manager v7, which stores through Keyv adapters. For Redis that's @keyv/redis:

// npm i @nestjs/cache-manager cache-manager @keyv/redis
@Module({
  imports: [
    CacheModule.registerAsync({
      isGlobal: true,                           // inject it anywhere without importing the module
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        stores: [createKeyv(config.getOrThrow("REDIS_URL"))],
        ttl: 60_000,                            // default TTL, in milliseconds
      }),
    }),
  ],
})
export class AppModule {}

Leave out stores and you get an in-process memory store — fine for one replica, wrong for three (the previous reading).

Using it in a service

@Injectable()
export class ProductsService {
  constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache, private readonly db: Db) {}

  getProduct(id: string) {
    return this.cache.wrap(`product:${id}`, () => this.db.findProduct(id), 60_000);
  }
}

get / set(key, value, ttlMs) / del are the cache-aside primitives. wrap(key, fn, ttl) is the whole read path in one call: return the cached value, or run fn, store its result and return it. Two properties worth knowing, checked against cache-manager 7.2: concurrent wrap calls for one key in the same process share a single run of fn, and a fn that throws is not cached — the next call runs it again.

What goes through Redis comes back as data. Keyv serializes values, so a class instance returns as a plain object (its methods are gone) and a Date returns as an ISO string.

CacheInterceptor — whole responses, keyed by URL

@Controller("products")
@UseInterceptors(CacheInterceptor)
export class ProductsController {
  @Get()           list(@Query() q: ListQuery) { ... }   // cached per URL, query string included
  @Get(":id")      findOne(@Param("id") id: string) { ... }
}

The interceptor caches the response of GET routes, keyed by the request URL. It skips other methods (unless the route sets an explicit @CacheKey()), and it can't cache a handler that injects @Res(). On a hit it returns the stored body without calling the handler — the lifecycle unit's short-circuit, so pipes and the handler don't run. In @nestjs/cache-manager 12 it also sets an X-Cache: HIT or MISS response header, which is the quickest way to see what it's doing.

The key is the whole trap. The URL doesn't say who is asking:

@Get("me")          // Alice requests it first; her profile is cached under "/me"
me(@Req() req) {}   // Bob requests it within the TTL and gets Alice's profile

That's a data leak, not a performance bug. Keep the interceptor off anything that depends on the user, a header, or a tenant — or subclass it and override trackBy(context) to build a key that includes them (returning undefined from trackBy skips caching for that request). @CacheKey() and @CacheTTL() override the key and TTL per route.

Caching the service call instead of the HTTP response is usually the better default: the service knows exactly which inputs the result depends on, and it can invalidate on writes. The response interceptor can't do either.

Complex
1 / 2

When Redis is down — fail open or fail closed, per use

Flag

"What happens when Redis is unreachable?" has a different right answer for each thing you put in it, and the libraries have already made some of those choices for you. The cache behaviour below was checked on Nest 12.1 with @nestjs/cache-manager 12.0, cache-manager 7.2 and @keyv/redis 6.1, pointed at a Redis URL with nothing listening; the rate-limiter behaviour was checked with @nestjs/throttler 6.7 and a storage whose increment() throws.

The cache: reads fail open, writes don't

The app still boots. Then:

  • cache.get() resolves undefined within milliseconds — cache-manager catches the store's error and reports a miss. Nothing is logged unless you listen for its events.
  • cache.set(), cache.del() and cache.wrap() (after running your loader) reject: "Redis client is not connected or has failed to connect".

So the straightforward cache-aside service — get, miss, load from the database, await set — turns a database read that succeeded into a 500. Catch around set and del, log, and return what the database gave you: the cache is an optimisation, and losing it should cost latency, not correctness. (CacheInterceptor already does this: it falls through to the handler and only logs a failed set.)

Failing open has a cost to plan for: every read now reaches the database. A hot key that was served from cache thousands of times a second becomes thousands of queries. Collapsing concurrent misses (the challenge's single-flight) and keeping database pool limits sane are what stop a cache outage from becoming a database outage.

The rate limiter: the guard fails closed — for every route

When the throttler's storage throws, ThrottlerGuard throws, and the client gets a 500. Registered with APP_GUARD, that's every guarded route: Redis being down takes the whole API with it. Owning the storage lets you choose per throttler:

class FallbackThrottlerStorage implements ThrottlerStorage {
  private readonly logger = new Logger(FallbackThrottlerStorage.name);
  private readonly local = new ThrottlerStorageService();      // the built-in in-memory one

  constructor(private readonly shared: ThrottlerStorage) {}

  async increment(key: string, ttl: number, limit: number, blockDuration: number, name: string) {
    try {
      return await this.shared.increment(key, ttl, limit, blockDuration, name);
    } catch (err) {
      this.logger.warn(`rate-limit store unavailable, limiting per instance: ${String(err)}`);
      return this.local.increment(key, ttl, limit, blockDuration, name);
    }
  }
}

Falling back to per-process counting keeps a limit in place — N replicas, so N times looser — instead of either "no limit" or "no API". For a login, OTP or password-reset limit, where the limit is the security control against guessing, failing closed is defensible — but only for those routes: refuse them with a 503 while Redis is down, and keep the rest of the API up.

Sessions: there's no fail-open

A session stored only in Redis can't be read without Redis. express-session 1.19 passes a store error to next(err) — a 500 — and if the store reports itself disconnected it skips sessions entirely, so req.session is undefined and your guard sees nobody logged in. Either way users are logged out for the duration. The choices are architectural: run Redis with replication and failover, or authenticate with something that doesn't need a server-side lookup on every request (a signed token), and accept what that costs you in revocation.

Kept in RedisWhen Redis is downUsual choice
Read cacheget misses, set/del rejectFail open to the database; protect the database
General rate limitGuard throws → 500 everywhereFail open to per-instance limits
Login / OTP limitSameFail closed on that route only
SessionsNobody is logged inMake Redis highly available

Quiz

A service caches with await this.cache.set(key, report, 60), meaning "for a minute". The database load hardly drops. Why?

Flag

const stock = await this.cache.get<number>(key); if (stock) return stock; — what goes wrong?

Flag

When a price changes, why delete the cached product rather than set it to the new value?

Flag

CacheInterceptor is bound globally. Shortly after Alice loads GET /me, Bob loads GET /me and sees Alice's profile. Why?

Flag

The API runs 3 replicas with ThrottlerModule.forRoot({ throttlers: [{ ttl: 60_000, limit: 100 }] }) and the default storage. Requests are spread evenly. How many requests a minute can one client make to one route before seeing a 429?

Flag

Behind a load balancer, with default settings, every user starts getting 429s at once whenever traffic is busy. What's the cause?

Flag

A global ThrottlerGuard allows 10 requests per minute. An attacker sends 10 a minute to each of POST /auth/login, POST /auth/login/otp and POST /auth/reset. What happens?

Flag

Redis is unreachable for ten minutes. Which of these should fail closed (refuse the request) rather than fall back?

Flag

Fill in the blank

Back CacheModule with Redis through Keyv, then inject the cache into a service.

Flag
CacheModule.registerAsync({
  isGlobal: true,
  useFactory: () => ({ stores: [("redis://cache:6379")] }),
});

constructor(@Inject() private readonly cache: Cache) {}

Cache a product for one minute.

Flag
await this.cache.set(`product:${id}`, product, );

Apply the throttler to every route, tighten it on login, and exempt the health check.

Flag
providers: [{ provide: , useClass: ThrottlerGuard }]

@({ default: { limit: 5, ttl: 60_000 } })
@Post("login") login() {}

@()
@Controller("health") export class HealthController {}

Behind a reverse proxy on the same host, make req.ip the real client address for the throttler's tracker.

Flag
app.set("", "loopback");

Predict the output

// CacheModule on @keyv/redis; Redis is unreachable. The database is fine.
async getProduct(id: string) {
  const key = `product:${id}`;
  const cached = await this.cache.get<Product>(key);
  if (cached !== undefined) return cached;
  const product = await this.db.findProduct(id);
  await this.cache.set(key, product, 60_000);
  return product;
}
Flag
ThrottlerModule.forRoot({ throttlers: [{ ttl: 60_000, limit: 3 }] })   // default storage
// Two replicas, A and B. One client sends GET /a eight times within a
// minute, and the load balancer alternates: A, B, A, B, A, B, A, B.
Flag
let loads = 0;
const load = async () => { loads++; await sleep(20); return { price: 10 }; };

await Promise.all([1, 2, 3, 4, 5].map(() => cache.wrap("p:1", load, 60_000)));

let fails = 0;
const broken = async () => { fails++; throw new Error("db down"); };
for (const _ of [1, 2]) await cache.wrap("p:2", broken).catch(() => {});

console.log(loads, fails);
Flag

Challenge

Make the product cache safe to run in production

Flag

product-cache.ts is the service a Nest app would wrap around @Inject(CACHE_MANAGER): cache-aside for product reads. On a read it checks the cache, falls back to the database on a miss, and writes what it loaded back to the cache.

cache-store.ts stands in for the injected cache — the same get / set / del as cache-manager v7 (the version under @nestjs/cache-manager in Nest 12), TTLs in milliseconds, undefined for a miss. It keeps values the way Redis does (as JSON) and can be taken down. Down, it does what cache-manager v7 over @keyv/redis does when Redis is unreachable: get reports a miss, while set and del reject. product-db.ts is the database, with a load counter and a switch that makes the next load fail. clock.ts lets the tests step past a TTL without waiting.

The service passes a happy-path demo. Four things break it under real traffic:

  1. No TTL. Entries never expire, so anything that changes the database behind the cache's back (an admin tool, a migration, another service) is never seen again.
  2. No invalidation. updatePrice writes the database and leaves the old price cached.
  3. Stampede. When a hot product isn't cached — after a deploy, after it expires — every request that arrives during the load runs its own database query.
  4. Outage. With Redis down, set rejects after the database has already answered, so a read that could have succeeded fails. The cache is an optimisation: losing it should cost speed, not correctness.

Your task

Edit product-cache.ts so the tests pass:

  • A product stays cached for PRODUCT_TTL_MS (milliseconds) and is reloaded from the database after that
  • After updatePrice, the next read returns the new price — even when another read ran while the database write was in flight
  • Ten concurrent reads of an uncached product run one database load
  • A failed load isn't cached: the caller gets the error, and the next read tries the database again
  • With the cache down, reads are answered from the database and updatePrice still succeeds

Loading the in-browser editor…

Further reading