Rate Limiting
Count requests per caller with @nestjs/throttler, tighten individual routes, and answer 429 with a Retry-After the client can obey.
ThrottlerModule.forRoot([{ name?, ttl, limit, blockDuration? }])Rate limiting caps how many requests one caller may make in a window. It is what stops a password-guessing script, an accidental retry loop, and a scraper that costs you a database. The @nestjs/throttler package provides it: ThrottlerModule holds the configuration, and ThrottlerGuard does the counting.
Where it runs
It is a guard, so it runs after all middleware and before any interceptor, pipe or handler. A blocked request never reaches your validation or your service. It also means the limit is decided from the raw request only; a guard cannot count by a field of a validated DTO.
How the counting works
Each throttler set is an independent counter with a ttl (the window, in milliseconds) and a limit. Define several, name them, and all of them must pass: 5 per second and 100 per minute. The tracker defaults to the caller's IP address; behind a proxy, set trust proxy on the adapter or override getTracker(), otherwise every request looks like it comes from the load balancer.
The response
Allowed responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. When the limit is reached the guard sets Retry-After, in seconds, and throws ThrottlerException, which is an HttpException with status 429 and the message ThrottlerException: Too Many Requests. Headers can be turned off per set with setHeaders: false.
What trips people up
The default storage is an in-memory map, per process. Run four replicas behind a load balancer and the effective limit is four times what you configured; point the storage option at a shared store to fix it.
Example
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import {
ThrottlerGuard,
ThrottlerModule,
minutes,
seconds,
} from '@nestjs/throttler';
import { AuthModule } from './auth/auth.module.js';
Module({
imports: [
AuthModule,
// The array form. Each entry is an independent counter and every one
// of them must pass. seconds()/minutes() just return milliseconds.
ThrottlerModule.forRoot([
{ name: 'burst', ttl: seconds(1), limit: 5 },
{ name: 'sustained', ttl: minutes(1), limit: 100 },
]),
],
providers: [
// Binds the guard to every route in the application. Without this,
// the module is configured but nothing is enforced anywhere.
{ provide: APP_GUARD, useClass: ThrottlerGuard },
],
})
export class AppModule {}When to use it
- Stop a credential-stuffing script by allowing three sign-in attempts a minute and blocking for fifteen.
- Give a public API a published quota and return Retry-After so well-behaved clients back off.
- Keep one tenant's retry loop from exhausting the connection pool for everyone else.
- Make the limit hold across replicas by moving the counters into Redis.
More examples
A tighter limit on sign-in, and a route with none
Use this whenever one route is far more expensive or more attractive to attack than the rest: sign-in, password reset, anything that sends an email or an SMS. The naming trap is the one that costs an afternoon - the moment you give your throttler sets names in forRoot(), the bare @SkipThrottle() and a @Throttle({ default: ... }) stop having any effect, silently, because 'default' is only the name of a set you never defined.
// src/auth/auth.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';
import { SkipThrottle, Throttle, minutes, seconds } from '@nestjs/throttler';
import { AuthService } from './auth.service.js';
import { SignInDto } from './dto/sign-in.dto.js';
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
// The keys are throttler NAMES. 'burst' and 'sustained' are the sets
// from forRoot(); 'sustained' still applies here because this only
// overrides 'burst'. blockDuration keeps the caller out for 15 minutes
// once the limit is hit, instead of only until the window rolls over.
@Throttle({
burst: { limit: 3, ttl: minutes(1), blockDuration: minutes(15) },
})
@Post('sign-in')
signIn(@Body() dto: SignInDto) {
return this.auth.signIn(dto);
}
// Skips the sets by name. @SkipThrottle() with no argument is shorthand
// for @SkipThrottle({ default: true }) - which skips NOTHING here,
// because neither set is called 'default'.
@SkipThrottle({ burst: true, sustained: true })
@Get('session')
session() {
return this.auth.currentSession();
}
// A per-route set that does not exist in forRoot() is simply ignored,
// so the name has to match. seconds(10) = 10000 ms.
@Throttle({ burst: { limit: 1, ttl: seconds(10) } })
@Post('password-reset')
requestReset(@Body('email') email: string) {
return this.auth.requestPasswordReset(email);
}
}The object longhand, with shared storage
The array form and the object form are the same configuration; the array is a shorthand for an object whose only property is throttlers. You need the long form as soon as you want a custom storage, an errorMessage, a skipIf, or ipv6SubnetPrefix - options that belong to the module rather than to one set. Any class implementing the ThrottlerStorage interface, exported from @nestjs/throttler, can be the storage.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule, minutes, seconds } from '@nestjs/throttler';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import Redis from 'ioredis';
@Module({
imports: [
// This is what forRoot([...]) desugars to. The array goes under
// `throttlers`; everything else applies to all the sets at once.
ThrottlerModule.forRoot({
throttlers: [
{ name: 'burst', ttl: seconds(1), limit: 5 },
{ name: 'sustained', ttl: minutes(1), limit: 100 },
],
// The built-in storage is an in-memory map, so each replica counts
// on its own. A shared store gives all of them one set of counters.
// Community package: npm i @nest-lab/throttler-storage-redis ioredis
storage: new ThrottlerStorageRedisService(
new Redis(process.env.REDIS_URL!),
),
// Replaces 'ThrottlerException: Too Many Requests' in the 429 body.
errorMessage: 'Too many requests. Try again shortly.',
// Runs per request before any counting. Useful for an internal
// caller that must never be throttled.
skipIf: (context) => {
const request = context.switchToHttp().getRequest<{ headers: Record<string, string> }>();
return request.headers['x-internal-key'] === process.env.INTERNAL_KEY;
},
}),
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}Counting the real client behind a proxy
Reach for this the first time a deployed app rate-limits everybody at once: every request arrives from the ingress, so they all share one counter. Two things are needed, not one - the adapter must trust the proxy before the forwarded addresses are parsed at all, and the guard must read them. Trusting a proxy you do not control lets a caller set their own X-Forwarded-For and evade the limit, so name the hop rather than passing true.
// src/throttler/throttler-behind-proxy.guard.ts
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
// The default tracker is req.ip, which behind a proxy is the proxy.
// req.ips is the X-Forwarded-For chain, left to right, and is only
// populated once the adapter trusts the proxy (see main.ts below).
protected async getTracker(req: Record<string, any>): Promise<string> {
return req.ips.length ? req.ips[0] : req.ip;
}
}
// src/main.ts
// import { NestFactory } from '@nestjs/core';
// import type { NestExpressApplication } from '@nestjs/platform-express';
//
// const app = await NestFactory.create<NestExpressApplication>(AppModule);
// // Without this, Express ignores X-Forwarded-For and req.ips is empty.
// // 'loopback' trusts a proxy on the same host; use the proxy's address
// // or hop count in production, never `true` on a public listener.
// app.set('trust proxy', 'loopback');
// src/app.module.ts
// providers: [{ provide: APP_GUARD, useClass: ThrottlerBehindProxyGuard }]
Discussion