Every public API I've operated has eventually met the same four characters: the scraper hammering search endpoints at 3 a.m., the credential-stuffing bot walking through leaked password lists on /auth/login, the well-meaning customer whose retry loop has no backoff and turns one failure into ten thousand requests, and the internal dashboard someone deployed with polling set to 100 milliseconds. None of them are hypothetical. All of them are solved — or at least contained — by the same unglamorous mechanism: rate limiting.
The reasons to limit go beyond abuse. Every request costs compute, database load, and often third-party API fees; a limit is a budget cap. Brute-force protection on auth endpoints is a security control, not a performance one. And once you sell API access in tiers, limits are the product — the difference between the $0 plan and the $500 plan is mostly a number in a rate limiter.
What took me longer to appreciate is that "rate limiting" is not one thing. The algorithm you pick changes what a burst looks like, what memory you spend per client, and whether your limit even holds across three replicas. So let's start there.
The four classic algorithms
Fixed window is the one everyone implements first: a counter per client per time bucket. user:42:10:04 → increment, reject past 100. Trivially cheap — one counter, one expiry. The flaw is the window boundary: a client can send 100 requests at 10:04:59 and 100 more at 10:05:00. Your "100/minute" limit just allowed 200 requests in two seconds — exactly the burst you were trying to prevent.
Sliding window fixes the boundary. The log variant stores a timestamp per request and counts entries within now - 60s — precise, but memory grows with the request rate, which is a bad property in the exact scenario (a flood) you built this for. The practical compromise is the sliding window counter: keep this window's count and the previous window's, and weight the previous one by how much of it still overlaps the trailing 60 seconds. Two counters, smooth behavior, no boundary exploit worth caring about.
Token bucket thinks in terms of allowance instead of counting. A bucket holds up to burst tokens; tokens refill at rate per second; each request spends one; empty bucket means rejected. The killer feature is that burst size and sustained rate are independent knobs: 10 requests/second sustained with a burst of 50 lets a client be spiky — which real clients always are — without letting them be sustained-heavy. This is what I default to for paid API tiers.
Leaky bucket is the mirror image: requests enter a queue that drains at a fixed rate, and overflow is rejected. Output traffic is perfectly smooth, which is lovely when you're protecting a fragile downstream — and annoying for interactive APIs, because "smooth" means legitimate bursts get queued behind delay rather than served.
| Algorithm | Burst handling | Cost per client | Boundary exploit | Best for |
|---|---|---|---|---|
| Fixed window | Allows 2× at edges | 1 counter | Yes | Cheap coarse limits, internal tools |
| Sliding window counter | Smooth, accurate | 2 counters | No | General-purpose API limits |
| Sliding window log | Exact | O(requests) | No | Low-volume, high-precision (auth) |
| Token bucket | Configurable burst + rate | 2 values | No | Public APIs, tiered SaaS plans |
| Leaky bucket | Queues, smooths output | Queue | No | Protecting fragile downstreams |
If you remember one thing from the table: fixed window is fine until someone hostile finds the boundary, and token bucket has knobs shaped like the promises you actually make to customers ("10 req/s, bursts to 50").
@nestjs/throttler: the right first step
NestJS ships a solution, and for a single-instance app it's genuinely good:
// app.module.ts
@Module({
imports: [
ThrottlerModule.forRoot({
throttlers: [
{ name: 'short', ttl: 1_000, limit: 10 }, // 10 per second
{ name: 'long', ttl: 60_000, limit: 300 }, // 300 per minute
],
}),
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}
Per-route overrides are decorators, which keeps the policy next to the endpoint it protects:
@Controller('auth')
export class AuthController {
@Post('login')
@Throttle({ short: { ttl: 60_000, limit: 5 } }) // brute-force cap
login(@Body() dto: LoginDto) { ... }
@Get('health')
@SkipThrottle()
health() { ... }
}
Two honest limitations. First, the default storage is in-memory, so with three replicas behind a load balancer each instance keeps its own counters — your "300/minute" limit is actually "up to 900/minute, depending on how the balancer distributes you." Second, the built-in tracker is IP-based, and IPs are a blunt instrument (more on that below). The first problem has an official fix — @nestjs/throttler accepts a Redis-backed storage (throttler-storage-redis) — and if you're already running Redis for caching, as described in Redis caching strategies in NestJS, plugging it in is a ten-minute change. But it's worth understanding what that storage has to do, because it's where correctness lives.
Distributed limits: Redis and atomicity
The naive distributed counter is a read-modify-write:
const count = await redis.get(key); // ← another instance can interleave here
if (Number(count) >= limit) reject();
await redis.incr(key);
Under concurrency, two instances both read 99, both allow, both increment — the limit leaks. The fix is making check-and-increment atomic. For a fixed window, INCR + EXPIRE in a pipeline is nearly enough (INCR is itself atomic; you just have to set the TTL only on the first hit). For anything smarter, a Lua script runs atomically on the Redis server — here's a compact token bucket:
-- KEYS[1] = bucket key, ARGV = rate, burst, now_ms, cost
local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens') or ARGV[2])
local ts = tonumber(redis.call('HGET', KEYS[1], 'ts') or ARGV[3])
local elapsed = (ARGV[3] - ts) / 1000
tokens = math.min(tonumber(ARGV[2]), tokens + elapsed * tonumber(ARGV[1]))
local allowed = tokens >= tonumber(ARGV[4])
if allowed then tokens = tokens - tonumber(ARGV[4]) end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', ARGV[3])
redis.call('PEXPIRE', KEYS[1], 60000)
return { allowed and 1 or 0, tokens }
Loaded once with SCRIPT LOAD and invoked by hash, this is a single round trip and a few microseconds of Redis CPU per request. Note the trick: the bucket refills lazily — no background process tops up tokens; the elapsed time since the last request computes the refill on demand. Two hash fields per client, self-expiring, and the limit holds across any number of app replicas because Redis is the single point of truth.
One operational note: decide your failure mode up front. If Redis is down, do you fail open (allow everything) or closed (reject everything)? For general endpoints I fail open with an alert — an outage shouldn't take the API down with it. For /auth/login I fail closed, because that's a security control, not a courtesy.
What to key on: IP, user, or API key
The tracker matters as much as the algorithm:
- Per-IP is the only option for unauthenticated traffic, and it's flawed in both directions: a university NAT or mobile carrier puts thousands of legitimate users behind one IP (false positives), while one attacker with a proxy pool has thousands of IPs (false negatives). Use it as a coarse outer wall, and if you're behind a proxy, take the client IP from a trusted
X-Forwarded-Forhop — trusting the raw header lets clients forge their identity. - Per-user is the right key the moment authentication happens. Limits follow the account, not the network location.
- Per-API-key is per-user for machine clients, with a bonus: a customer can have separate keys for separate integrations, each with its own budget, and revoking one doesn't touch the others.
In practice I layer them: a generous per-IP limit at the edge (often in nginx or the API gateway before NestJS ever sees the request), a per-user or per-key limit in the app where business context lives, and a strict per-IP-and-per-account limit on auth endpoints — keying login attempts by account name as well as IP is what actually blunts distributed credential stuffing. In a microservices setup, the gateway is the natural home for the coarse layers — one of the cross-cutting concerns I touched on in NestJS microservices architecture.
Rejecting politely: 429 and Retry-After
A rate limiter's response is an API contract. Well-behaved clients want to back off; tell them how:
@Catch(ThrottlerException)
export class RateLimitFilter implements ExceptionFilter {
catch(_: ThrottlerException, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse<Response>();
res
.status(429)
.header('Retry-After', '30') // seconds
.header('X-RateLimit-Limit', '100')
.header('X-RateLimit-Remaining', '0')
.header('X-RateLimit-Reset', String(resetEpoch))
.json({
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded. Retry after 30 seconds.',
});
}
}
Retry-After is the important one — standard, and honored by serious HTTP clients and SDK retry policies. The X-RateLimit-* family lets clients pace themselves before hitting the wall, which is cheaper for everyone than rejection-driven discovery. Send the limit headers on successful responses too, not just on 429s. Stripe does this on every response, and it's a big part of why integrating against their API feels civilized — the same attention to client ergonomics I dug into in the Stripe payment guide.
Tiered limits for SaaS plans
Once limits are Redis-backed and keyed by API key, tiering is just configuration:
const PLAN_LIMITS: Record<Plan, { rate: number; burst: number }> = {
free: { rate: 1, burst: 10 }, // 1 req/s, bursts to 10
pro: { rate: 20, burst: 100 },
scale: { rate: 200, burst: 1000 },
};
@Injectable()
export class TieredThrottlerGuard implements CanActivate {
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest();
const { rate, burst } = PLAN_LIMITS[req.apiKey.plan];
const { allowed, retryAfter } = await this.limiter.consume(
`rl:${req.apiKey.id}`, rate, burst,
);
if (!allowed) throw new RateLimitException(retryAfter);
return true;
}
}
Look up the plan from a cached key record (this lookup is itself a caching problem — don't hit Postgres per request), feed the plan's numbers into the same Lua token bucket, done. Upgrades take effect on the next request with no deploy. Two refinements worth adding once money is involved: per-endpoint weights (an export costing 10 tokens while a read costs 1 reflects real resource cost), and a soft-limit grace band for paying customers — alert at 100%, enforce at 120% — because hard-rejecting your best customer's Black Friday traffic spike is a support ticket you don't want.
Wrapping up
Rate limiting is layered defense, and each layer is a small decision done well: pick the algorithm whose burst behavior matches your promise (token bucket for public tiers, sliding window for general traffic, strict small windows for auth), start with @nestjs/throttler but move the counters into Redis the day you run a second replica, make the check-and-increment atomic with Lua rather than hoping read-modify-write races don't bite, key limits on who the client is rather than just where they connect from, and return 429s with Retry-After so cooperative clients can cooperate.
None of it is difficult in isolation. The failure mode I see is not bad implementations but missing ones — the API that had no limits because "we're not big enough yet," meeting its first scraper. Ship a generous limit early. Tightening a limit you have is a config change; adding one during an incident is a very long night.