Caching & rate limits with Redis
- 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 andundefinedmisses, and spot whenCacheInterceptor's URL key serves one user's response to another - Configure
@nestjs/throttlerv6 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
Cache-aside — a copy that's allowed to be a little stale
FlagA 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:
- The database is the truth. The cache never holds anything the database doesn't.
- 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. - 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.
CacheModule on Redis, and what CacheInterceptor keys on
FlagNest'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.
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()resolvesundefinedwithin 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()andcache.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 Redis | When Redis is down | Usual choice |
|---|---|---|
| Read cache | get misses, set/del reject | Fail open to the database; protect the database |
| General rate limit | Guard throws → 500 everywhere | Fail open to per-instance limits |
| Login / OTP limit | Same | Fail closed on that route only |
| Sessions | Nobody is logged in | Make 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?
const stock = await this.cache.get<number>(key); if (stock) return stock; — what goes wrong?
When a price changes, why delete the cached product rather than set it to the new value?
CacheInterceptor is bound globally. Shortly after Alice loads GET /me, Bob loads GET /me and sees Alice's profile. Why?
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?
Behind a load balancer, with default settings, every user starts getting 429s at once whenever traffic is busy. What's the cause?
FlagA 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?
Redis is unreachable for ten minutes. Which of these should fail closed (refuse the request) rather than fall back?
FlagFill in the blank
Back CacheModule with Redis through Keyv, then inject the cache into a service.
CacheModule.registerAsync({ isGlobal: true, useFactory: () => ({ stores: [("redis://cache:6379")] }), }); constructor(@Inject() private readonly cache: Cache) {}
Cache a product for one minute.
Flagawait this.cache.set(`product:${id}`, product, );
Apply the throttler to every route, tighten it on login, and exempt the health check.
Flagproviders: [{ 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.
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;
}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.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);Challenge
Make the product cache safe to run in production
Flagproduct-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:
- 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.
- No invalidation.
updatePricewrites the database and leaves the old price cached. - 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.
- Outage. With Redis down,
setrejects 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
updatePricestill succeeds