Stores: Redis vs In-Memory
The rate limit check runs on the critical path of every request. The plugin ships two interchangeable store backends so you can choose, per route, between an exact distributed limit and a zero-latency per-instance one.
store: 'redis' (default) | store: 'memory' | |
|---|---|---|
| Counter location | Redis, shared by all instances | Process memory, per instance |
| Request-path cost | One Redis round-trip | Microseconds, no I/O |
| Global limit | Exact | Approximate (~ per-node limit × node count) |
| Survives restarts | Yes (until TTL) | No (deploy resets counters) |
reset() / getState() | Global | Per-instance |
| Fits | Auth throttling, quotas, exact contracts | Anti-abuse and overload protection on bulk traffic |
When the Memory Store Shines
If your Redis deployment adds latency to the hot path — a far cluster node, cross-AZ hops — the memory store removes the rate-limit round-trip from every request. Note the cluster detail: a given key hashes deterministically to one shard, so with a far node some users always pay the slow path on every request; the memory store eliminates that entirely.
Per-instance limiting is standard industry practice for abuse protection: nginx limit_req is per-instance only, Envoy recommends its local rate limit filter as the first line of defense even alongside a global one, and Kong ships a local policy. The trade-off they all accept: with N even-loaded nodes, a spraying client gets roughly N× the per-node limit. Size the per-node limit as globalTarget / expectedNodeCount and treat it as an abuse ceiling, not an exact quota.
Keep security-sensitive routes on redis
A distributed brute force divides its attempts across your nodes, so a per-node threshold on login, OTP, or password-reset routes is effectively multiplied by the node count. Pin those routes to the exact store — they are low-QPS, the Redis round-trip does not hurt there. The same applies to billing quotas and monetized plan enforcement.
Choosing a Plugin Default
Register the plugin with store: 'memory' when most routes need cheap anti-abuse limiting; the sensitive few override back to Redis:
import { Module } from '@nestjs/common';
import { RedisModule } from '@nestjs-redisx/core';
import { RateLimitPlugin } from '@nestjs-redisx/rate-limit';
@Module({
imports: [
RedisModule.forRoot({
clients: {
host: 'localhost',
port: 6379,
},
plugins: [
new RateLimitPlugin({
// Per-instance counters in process memory: zero Redis round-trip
// on the request path. Each node enforces its own limit, so the
// effective global limit is roughly per-node limit x node count.
store: 'memory',
defaultPoints: 300,
defaultDuration: 60,
// Memory safety: cap the number of tracked keys and sweep expired
// entries periodically (protects against random-key spray).
memory: {
maxKeys: 100_000,
sweepIntervalMs: 30_000,
},
}),
],
}),
],
})
export class AppModule {}Per-Route Selection
The decorator's store option overrides the plugin default in either direction:
import { Controller, Get, Post } from '@nestjs/common';
import { RateLimit } from '@nestjs-redisx/rate-limit';
/**
* Plugin default is `store: 'memory'` — bulk traffic pays zero Redis
* round-trip. Sensitive routes override back to the exact, distributed
* Redis store per route. The override works in either direction.
*/
@Controller()
export class ApiController {
// Uses the plugin default store ('memory'): cheap per-instance limiting.
@Get('feed')
@RateLimit({ points: 100, duration: 60 })
getFeed() {
return { items: [] };
}
// Auth-sensitive route pinned to Redis: the count is exact and shared by
// ALL instances. A distributed brute force cannot multiply the limit by
// the node count here.
@Post('login')
@RateLimit({ store: 'redis', points: 5, duration: 300 })
login() {
return { ok: true };
}
// The reverse also works: with a 'redis' plugin default, a hot endpoint
// can opt into per-instance memory counting.
@Get('health-details')
@RateLimit({ store: 'memory', points: 30, duration: 60 })
healthDetails() {
return { status: 'ok' };
}
}The same override is available programmatically on every service call:
await rateLimitService.check(`login:${email}`, {
store: 'redis',
points: 5,
duration: 300,
});Reset and Inspection Semantics
reset(key) with no arguments sweeps both stores across all algorithm variants — the service cannot know where a key was counted. Pass { store } to target one:
import { Injectable, Inject } from '@nestjs/common';
import { RATE_LIMIT_SERVICE, IRateLimitService } from '@nestjs-redisx/rate-limit';
@Injectable()
export class AdminService {
constructor(
@Inject(RATE_LIMIT_SERVICE)
private readonly rateLimitService: IRateLimitService,
) {}
async unblockUser(userId: string): Promise<void> {
// Default: sweeps BOTH stores across all algorithm variants.
// Redis-backed keys are cleared globally; the memory store is cleared
// only on the instance that handles this call (other instances keep
// their short-lived local counters until the window expires).
await this.rateLimitService.reset(`user:${userId}`);
}
async unblockLogin(email: string): Promise<void> {
// Target one store when you know where the key is counted.
await this.rateLimitService.reset(`login:${email}`, { store: 'redis' });
}
async inspect(userId: string) {
// peek/getState honor the store selection too.
const distributed = await this.rateLimitService.peek(`user:${userId}`, {
store: 'redis',
points: 100,
duration: 60,
});
const local = await this.rateLimitService.peek(`user:${userId}`, {
store: 'memory',
points: 100,
duration: 60,
});
return {
distributed: distributed.current,
// NOTE: per-instance value — only this node's view of the counter
localOnThisInstance: local.current,
};
}
}Semantics to keep in mind:
- Redis keys reset globally — one call clears the counter for every instance at once. This is exactly the store your admin "unblock user" flows should target.
- Memory keys reset per instance — the call clears the counter only on the node that served it. Other nodes self-heal when the short window expires. If a route genuinely needs admin reset or precise inspection, that is the signal it belongs on
store: 'redis'. peek()/getState()honorconfig.storeand report the same per-instance view for memory-backed keys.- For observability of memory-backed limits, use the request counters from the Metrics plugin (each instance exports its own series) rather than
getState().
Memory Sizing
Redis bounds its keyspace with EXPIRE; the memory store bounds itself:
memory.maxKeys(default100000) — cap on tracked keys. When exceeded, the oldest entries are evicted (approximate FIFO). This is the defense against key spray: random IPs or spoofed API keys cannot grow the map without bound.memory.sweepIntervalMs(default30000) — how often expired entries are swept. Entries are also lazily discarded on access, so the sweep only controls how quickly idle garbage is reclaimed.
A tracked key costs on the order of a hundred bytes (sliding-window entries also hold up to points timestamps), so the default cap stays in the tens of megabytes even under attack.
Both Stores Are Always Available
Both adapters register regardless of the store default, so per-route overrides work without extra configuration. The Redis connection remains required (the core module owns it); the memory store removes Redis from the request path, not from the deployment. Counters for the same logical key in the two stores are fully independent — they never synchronize.
Next Steps
- Configuration — All plugin options
- Decorator —
@RateLimitreference - Service API — Programmatic checks, peek, reset
- Monitoring — Metrics for allowed/rejected requests