Rate Limiting and Throttling
- nestjs
- nodejs
- redis
- backend
- security
- api
- typescript
Last time, we wired up transactional email sending: welcome messages, password resets, the notifications a SaaS product can't ship without. That article assumed a well-behaved client calling your API a reasonable number of times. This one deals with what happens when that assumption breaks, whether from a bug in a client retry loop, a scraper hammering your public endpoints, or someone testing your login route with a password list.
This is part of the NestJS Production Guide series, and rate limiting sits squarely in the production-hardening phase. You build it for yourself, as an operator, so a single client (malicious or just broken) can't take down the service for everyone else.
I'll cover the built-in NestJS throttler, why the default in-memory store stops working the moment you run more than one instance, and the pitfalls that actually bite in production: proxies rewriting IPs, throttling authenticated users the wrong way, and picking limits that are either useless or hostile.
Why rate limiting is a production decision, not an afterthought
Rate limiting solves three distinct problems. It helps to name them separately, because the right limit depends on which one you're solving for.
The first is abuse prevention. Login endpoints, password reset requests, and anything that triggers an email or SMS are attractive targets for credential stuffing or enumeration. A limit here matters less for traffic volume and more for making brute-force attempts economically pointless.
The second is cost and resource protection. If an endpoint calls a paid third-party API, hits a slow database query, or triggers a background job, an unbounded client can generate real cost or degrade the database for everyone else sharing it. This is where I've seen teams get burned: a client-side bug causes a retry storm, and the "harmless" endpoint quietly saturates a connection pool.
The third is fairness. In a multi-tenant SaaS, one tenant's traffic spike shouldn't degrade the experience for every other tenant on the same infrastructure. This is a weaker justification for rate limiting specifically (it often belongs to a broader per-tenant quota system), but it's the same mechanism at a different granularity.
None of these problems require you to build your own token bucket. NestJS ships a throttler module that handles the mechanics; your job is deciding where to apply it and how strict to be.
Adding @nestjs/throttler
The @nestjs/throttler package gives you a guard that counts requests per client within a time window and rejects once the count exceeds the limit. Install it and register it as a global guard so every route is protected by default.
npm install @nestjs/throttler
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRoot([
{
name: 'default',
ttl: 60_000, // window, in milliseconds
limit: 100, // max requests per client per window
},
]),
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}
With this in place, every request is tracked against a rolling window keyed by client IP, and anything past the limit gets a 429 Too Many Requests automatically. That single block covers the baseline case: a sane default for the whole API, so nobody has to remember to add protection to a new controller.
The name field matters once you need more than one throttling profile. You can register several named configurations (a loose one for general reads, a strict one for auth) and reference them by name where you need something other than the default.
Tuning limits per route
The global default is a safety net, not the whole strategy. Auth endpoints need a much tighter limit than general API reads, and some endpoints (webhooks, health checks) shouldn't be throttled by the client-facing rules at all.
// src/modules/auth/auth.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { Throttle, SkipThrottle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@Post('login')
login(@Body() dto: LoginDto) {
return this.auth.login(dto);
}
@SkipThrottle()
@Post('webhook')
handleWebhook(@Body() payload: unknown) {
return this.auth.handleProviderWebhook(payload);
}
}
Five login attempts per minute per client is deliberately tight. It's annoying for a user who mistypes a password twice. It's tolerable if they need five tries, and it makes a wordlist attack impractical for a script. Pick numbers like this by thinking through the legitimate worst case, not by reaching for a round number.
@SkipThrottle() matters for webhooks specifically. Those requests come from a third party (Stripe, a payment provider, a queue broker), so you don't control their IP or request pattern, and blocking a genuine retry is a worse outcome than leaving the route unprotected by this particular guard. If a webhook route needs protection, use signature verification and idempotency keys instead of request-count throttling.
Keying by user instead of just IP
The default tracker keys on IP address, which is a reasonable default and also the first thing that breaks in a real deployment. Office networks, corporate VPNs, and mobile carriers all put many real users behind one IP, so an IP-based limit can throttle a whole office because one person triggered it. Once you have authenticated users, keying on user ID instead is usually the better signal.
// src/common/guards/user-throttler.guard.ts
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import type { Request } from 'express';
@Injectable()
export class UserThrottlerGuard extends ThrottlerGuard {
protected async getTracker(req: Request & { user?: { id: string } }): Promise<string> {
// fall back to IP for anonymous requests
return req.user?.id ?? req.ip;
}
}
Swap this guard in for routes that sit behind authentication, where req.user is populated by an earlier guard or middleware. Anonymous routes still fall back to IP, which is the correct behavior since there's no better identity to key on before login.
One detail that catches people out: if your app sits behind a load balancer or reverse proxy (which is nearly every production deployment), req.ip reflects the proxy's address unless you explicitly trust the forwarding header.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.set('trust proxy', 1); // trust the first hop (your load balancer)
await app.listen(3000);
}
bootstrap();
Without trust proxy, every request looks like it's coming from the same internal IP, and your per-client limit silently becomes a limit for the entire service.
Making it work across multiple instances with Redis
The default throttler storage lives in the process memory of a single NestJS instance. That's fine for a single container and misleading the moment you run more than one, because each instance keeps its own count. A client hitting a two-instance deployment effectively gets double the limit you configured, since each instance only sees half the requests.
The fix is a shared storage backend. @nestjs/throttler supports a pluggable ThrottlerStorage, and the common choice is Redis, since you likely already run it for caching or sessions.
npm install @nestjs/throttler-storage-redis ioredis
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ThrottlerStorageRedisService } from '@nestjs/throttler-storage-redis';
import Redis from 'ioredis';
@Module({
imports: [
ThrottlerModule.forRootAsync({
useFactory: () => ({
throttlers: [{ ttl: 60_000, limit: 100 }],
storage: new ThrottlerStorageRedisService(
new Redis(process.env.REDIS_URL as string),
),
}),
}),
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}
This is the same mechanism, just with the count stored somewhere every instance can read and write. It costs one round trip to Redis per request, which is a fair tradeoff for a limit that's actually accurate. If you're already using Redis for sessions or caching, this adds no new operational dependency, which is the deciding factor for me: I wouldn't stand up Redis solely for rate limiting on a small deployment, but I would use it the moment it's already in the stack.
If you're still on a single instance, skip this section entirely. In-memory storage is simpler, has zero extra latency, and is correct for exactly the deployment you have. Add Redis-backed storage when you actually scale horizontally, not before.
Production pitfalls
A few mistakes show up repeatedly once rate limiting is live in front of real traffic.
Throttling before authentication runs. If the throttler guard executes before a request is authenticated, you can't key on user ID yet, which is one reason getTracker needs a safe IP fallback rather than throwing.
No visibility into who's getting throttled. A 429 with no logging tells you nothing when a customer complains their integration stopped working. Log throttled requests with enough context (route, tracker key, limit name) to debug a complaint without guessing.
One limit for every route. A single global number is either too loose for sensitive endpoints or too strict for legitimate bulk operations like CSV exports or bulk API calls. Segment limits by endpoint sensitivity, not by convenience.
Forgetting the client's perspective. Return the standard rate limit headers so well-behaved clients can back off gracefully instead of retrying blindly into a wall. @nestjs/throttler sets X-RateLimit-* headers by default; don't strip them at the proxy layer.
Testing against production limits. Automated tests and load tests can trip your own throttler if it's not disabled or loosened in the test environment, producing flaky failures that look like application bugs.
Key takeaways
- Rate limiting solves three different problems (abuse prevention, resource protection, fairness), and the right limit depends on which one you're addressing.
- `@nestjs/throttler` as a global `APP_GUARD` gives you a sane baseline; override per route with `@Throttle()` and `@SkipThrottle()` for the endpoints that need something different.
- Key sensitive, authenticated routes on user ID rather than IP, since IP-based limiting can punish an entire office or carrier NAT for one user's behavior.
- The default in-memory storage only works correctly on a single instance; move to a shared Redis-backed store the moment you run more than one.
- Set `trust proxy` correctly behind a load balancer, or every client will appear to share one IP and your limits will misfire.
- Log throttled requests and return standard rate limit headers so both you and well-behaved clients can react to being limited.
Frequently asked questions
What's the difference between rate limiting and throttling?
In practice they're used interchangeably, and NestJS's own package is named "throttler" for a mechanism most people call rate limiting. Some teams reserve "throttling" for slowing a client down (delaying responses) and "rate limiting" for rejecting requests outright, but `@nestjs/throttler` implements the reject-based approach.
Should rate limiting be global or per route?
Both. Register a global guard as a baseline so nothing is accidentally unprotected, then override the limit on specific routes (auth, password reset, expensive operations) where the default number is wrong in either direction.
Does rate limiting replace the need for a Web Application Firewall or CDN-level protection?
No. Application-level throttling protects your service logic and database from a client that's authenticated or has already reached your API. A WAF or CDN-level rate limiter (like one in front of a load balancer) is a separate, earlier layer that can absorb much larger or more distributed attacks before they reach your NestJS process at all.
Why does my rate limit seem to allow more requests than I configured?
The most common cause is running multiple instances with the default in-memory storage, where each instance tracks its own count independently. Switch to a shared store like Redis so the count is accurate across every instance.
How strict should the limit be on a login endpoint?
Tight enough that a wordlist attack is impractical, loose enough that a real user mistyping their password a couple of times isn't locked out. A handful of attempts per minute per client, paired with account lockout or CAPTCHA after repeated failures, is a reasonable starting point; tune it against your own abuse patterns rather than a number from a blog post.
Can rate limiting be bypassed by rotating IP addresses?
Yes, IP-based limiting alone won't stop a determined attacker with access to many IPs. That's exactly why authenticated routes should key on user ID where possible, and why sensitive endpoints benefit from a second layer (CAPTCHA, device fingerprinting, or account-level lockout) rather than relying on IP throttling as the only control.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.