Skip to content
Kavindu's Blog
Go back

Stop the Flood: Rate Limiting Your NestJS API the Right Way

Kavindu Manahara

Stop the Flood: Rate Limiting Your NestJS API the Right Way

There is a specific kind of dread that comes from opening your server logs and seeing the same IP address make fifty thousand requests in an hour. Most of them wrong passwords.

Your server is technically fine. The responses are going out. But the database connection pool is sweating, your legitimate users are waiting a little longer than usual, and somewhere out there a script is still running through a list of email addresses it did not write itself.

That is the moment most people add rate limiting. This post is about doing it before that moment comes.

Why bother

A rate limiter is a counter. Every request increments it. Once it crosses a threshold, the server skips the actual work and returns a 429 instead. That is the whole mechanism.

Without it, any client can send as many requests as their internet connection allows. A login endpoint becomes a brute-force target. An AI feature that costs money per call becomes something anyone can drain. A data export route becomes a scraper. None of these feel like real risks until one of them happens. Then they all feel obvious.

The built-in throttler

NestJS ships with an official package for rate limiting. Install it:

npm install @nestjs/throttler

Configure it inside your root module. Pulling the values from environment variables means you can adjust limits without touching code:

// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ThrottlerModule, seconds } from '@nestjs/throttler';

@Module({
  imports: [
    ConfigModule.forRoot(),
    ThrottlerModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => [
        {
          ttl: seconds(config.get<number>('THROTTLER_TTL', 60)),
          limit: config.get<number>('THROTTLER_LIMIT', 100),
        },
      ],
    }),
  ],
})
export class AppModule {}

The seconds() helper (imported from @nestjs/throttler) converts seconds to milliseconds, which is what the throttler expects internally. Using it keeps your env file readable — THROTTLER_TTL=60 stays a human-friendly number.

Add these to your .env:

THROTTLER_TTL=60
THROTTLER_LIMIT=100

With the defaults above, any client can make at most 100 requests per minute before they start getting rejected.

Applying it globally

Configuring the module does nothing on its own. You also need to register the guard. The cleanest way is as a global guard, which protects every route automatically:

// app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule, seconds } from '@nestjs/throttler';

@Module({
  imports: [
    ThrottlerModule.forRoot([
      {
        ttl: seconds(60),
        limit: 100,
      },
    ]),
  ],
  providers: [
    {
      provide: APP_GUARD,
      useClass: ThrottlerGuard,
    },
  ],
})
export class AppModule {}

Once this is in place, every incoming request goes through the throttler. Exceeding the limit returns a 429 with a Retry-After header. No per-route configuration needed.

Skipping or tightening specific routes

A global limit does not fit every route. A health check gets polled aggressively by load balancers and should not be throttled at all. A login endpoint deserves something tighter than the default.

To exclude a route entirely:

import { Controller, Get } from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';

@Controller()
export class HealthController {
  @SkipThrottle()
  @Get('health')
  healthCheck() {
    return { status: 'ok' };
  }
}

To set a tighter limit on a specific route:

import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { Throttle, seconds } from '@nestjs/throttler';

@Controller('auth')
export class AuthController {
  @Throttle({ default: { ttl: seconds(60), limit: 5 } })
  @Post('login')
  @HttpCode(HttpStatus.OK)
  async login(@Body() dto: LoginDto) {
    return this.authService.login(dto);
  }
}

Five attempts per minute on the login endpoint is a reasonable starting point. Legitimate users will never notice it. Someone running a credential-stuffing script will hit it on the first batch.

When you need something more precise

The built-in throttler tracks by IP address. That covers the common case well, but it has a blind spot: it knows nothing about who the user is or what the request actually does.

Consider an application with AI features like voice transcription or document parsing. Each call goes to a third-party service and costs real money. If a logged-in user calls the transcription endpoint 500 times in one hour, an IP-based per-minute limit will not stop them, because those requests might come from a home connection spread across a full hour.

What you need instead is a per-user, per-hour counter.

The idea is straightforward. When a request comes in, you build a Redis key from the user ID, the endpoint name, and the current hour. You call INCR on that key. If the result is 1, the key is new, so you set an expiry on it. If the count goes over the limit, you return 429. When the clock ticks to the next hour, the key changes automatically and the user gets a fresh counter.

Redis fits this well for two reasons. INCR is atomic, so two concurrent requests incrementing the same key will always get distinct values, never the same one. And key expiry is built in, so you don’t need a cron job or a cleanup routine to reset the counters each hour.

The guard is the right place to put this in NestJS because it runs before the route handler and has access to the request context (which is where the user ID lives). You register it globally alongside the throttler guard, but use a custom decorator on each route to opt in. Routes that don’t have the decorator return true immediately and pay no cost.

Here is what the full guard looks like:

// common/guards/ai-rate-limit.guard.ts
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';

const HOURLY_LIMITS: Record<string, number> = {
  transcribe: 20,
  'parse-form': 30,
  'ai-parse': 20,
};

@Injectable()
export class AiRateLimitGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    @InjectRedis() private readonly redis: Redis,
  ) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const endpoint = this.reflector.get<string>('ai_rate_limit_endpoint', ctx.getHandler());
    if (!endpoint) return true;

    const req = ctx.switchToHttp().getRequest();
    const userId: string | undefined = req.user?.sub;

    // unauthenticated requests are handled by the auth guard
    if (!userId) return true;

    const hour = new Date().toISOString().slice(0, 13).replace(/[-T:]/g, '');
    const key = `ai_rl:${userId}:${endpoint}:${hour}`;

    const count = await this.redis.incr(key);
    if (count === 1) {
      // 75-minute expiry gives a safety margin for keys created near the hour boundary
      await this.redis.expire(key, 4500);
    }

    const limit = HOURLY_LIMITS[endpoint] ?? 20;

    if (count > limit) {
      const now = new Date();
      const nextHour = new Date(now);
      nextHour.setHours(now.getHours() + 1, 0, 0, 0);
      const retryAfterSeconds = Math.ceil((nextHour.getTime() - now.getTime()) / 1000);

      throw new HttpException(
        {
          message: 'AI hourly rate limit exceeded. Resets at the next hour.',
          retryAfterSeconds,
        },
        429,
      );
    }

    return true;
  }
}

The Redis key includes the user ID, endpoint name, and the current hour as a compact string (something like 2026080816). When the clock rolls to the next hour, the key changes and the user gets a fresh counter. The expiry is set to 4500 seconds rather than 3600 because a key created at 2:59 PM still needs to survive into the 3:00 PM window. (That extra 15 minutes is a safety margin for the odd edge case.)

Wire it up with a small decorator:

// common/decorators/ai-rate-limit.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const AI_RATE_LIMIT_KEY = 'ai_rate_limit_endpoint';

export const AiRateLimit = (endpoint: 'transcribe' | 'parse-form' | 'ai-parse') =>
  SetMetadata(AI_RATE_LIMIT_KEY, endpoint);

The decorator and the guard are two ends of the same pipe. SetMetadata writes the endpoint name onto the route handler as metadata. The guard reads it back with this.reflector.get using the same key. Same key, same value. If the decorator is not on a route, reflector.get returns undefined and the guard returns true immediately — so only routes you explicitly mark get the hourly limit applied.

Then on the controller:

import { Body, Controller, Post } from '@nestjs/common';
import { AiRateLimit } from '@common/decorators/ai-rate-limit.decorator';

@Controller('voice')
export class VoiceController {
  @AiRateLimit('transcribe')
  @Post('transcribe')
  async transcribe(@Body() dto: TranscribeDto) {
    return this.voiceService.transcribe(dto);
  }
}

Easy to find when reading the controller. Easy to add to new routes. And if you need to raise or lower the limit for a specific endpoint, there is one place to change it.

One thing that is easy to forget: the guard does nothing unless it is registered. Add it to providers in app.module.ts alongside the throttler guard:

providers: [
  { provide: APP_GUARD, useClass: ThrottlerGuard },
  { provide: APP_GUARD, useClass: AiRateLimitGuard },
],

Without that, the decorator attaches metadata to the route but nothing ever reads it.

To put it all together, here is the full flow from a single request hitting an AI endpoint:

flowchart TD
    A["@AiRateLimit('transcribe') on route handler<br/>writes endpoint name as metadata"] --> B[Request arrives]
    B --> C[AiRateLimitGuard.canActivate fires]
    C -->|reflector.get reads metadata| D["Build Redis key<br/>ai_rl:userId:transcribe:hour"]
    D -->|redis.incr atomic| E{count over limit?}
    E -->|No| F["return true<br/>request continues"]
    E -->|Yes| G["throw 429<br/>with retryAfterSeconds"]

The decorator sets the label. The guard reads the label. Redis keeps the score. Nothing else needs to know about any of it.

Actually testing it

Reading about rate limiting and proving it works are two different things. Here is a Python script that fires a configurable number of concurrent requests per second at any endpoint, logs every response, and prints a breakdown when it finishes.

What you need

Python 3.8 or later and one package:

pip install aiohttp

The script

#!/usr/bin/env python3

import asyncio
import aiohttp
import time
from collections import Counter
import traceback
from datetime import datetime

LOG_FILE = "responses.log"
log_lock = asyncio.Lock()

# -------------------------
# Edit these
# -------------------------

URL = "http://localhost:3000/api/v1/auth/login"
METHOD = "POST"

HEADERS = {
    "Content-Type": "application/json",
}

PAYLOAD = {"email": "user@example.com", "password": "YourPassword"}

REQUESTS_PER_SECOND = 1000
DURATION_SECONDS = 10
TIMEOUT = 90

# -------------------------

results = Counter()

async def send_request(session):
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
    try:
        async with session.request(
            METHOD,
            URL,
            json=PAYLOAD if METHOD != "GET" else None,
            headers=HEADERS,
        ) as response:
            body = await response.text()
            results[f"HTTP {response.status}"] += 1
            async with log_lock:
                with open(LOG_FILE, "a", encoding="utf-8") as f:
                    f.write(
                        f"\n{'=' * 80}\n"
                        f"Time      : {timestamp}\n"
                        f"Status    : {response.status}\n"
                        f"Response  :\n{body}\n"
                    )
    except asyncio.TimeoutError:
        results["Timeout"] += 1
    except Exception as e:
        results[type(e).__name__] += 1
        async with log_lock:
            with open(LOG_FILE, "a", encoding="utf-8") as f:
                f.write(f"\nException: {traceback.format_exc()}\n")

async def worker(session):
    start = time.perf_counter()
    while time.perf_counter() - start < DURATION_SECONDS:
        batch_start = time.perf_counter()
        tasks = [asyncio.create_task(send_request(session)) for _ in range(REQUESTS_PER_SECOND)]
        await asyncio.gather(*tasks)
        elapsed = time.perf_counter() - batch_start
        if elapsed < 1:
            await asyncio.sleep(1 - elapsed)

async def main():
    timeout = aiohttp.ClientTimeout(total=TIMEOUT)
    connector = aiohttp.TCPConnector(limit=0)
    async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
        start = time.perf_counter()
        await worker(session)
        duration = time.perf_counter() - start

    total = sum(results.values())
    print("\n========== Result ==========")
    print(f"Duration          : {duration:.2f}s")
    print(f"Target RPS        : {REQUESTS_PER_SECOND}")
    print(f"Total Requests    : {total}")
    print(f"Average RPS       : {total / duration:.2f}")
    print()
    for key, value in sorted(results.items()):
        print(f"{key:<15}: {value}")

if __name__ == "__main__":
    asyncio.run(main())

Configuration

Update the values at the top to match your endpoint:

VariableWhat to put here
URLYour local or staging endpoint
METHODGET, POST, PUT, PATCH, or DELETE
HEADERSAny auth tokens or content type headers your endpoint requires
PAYLOADRequest body (ignored automatically for GET requests)
REQUESTS_PER_SECONDStart lower than you think. 50 RPS is more than enough to test a 100/min limit.
DURATION_SECONDS5 seconds at 50 RPS sends 250 requests total, which will clearly show the cutoff.

Run it:

python3 rate_limit_test.py

When it finishes you will see something like this:

========== Result ==========
Duration          : 10.03s
Target RPS        : 1000
Total Requests    : 10000
Average RPS       : 996.72

HTTP 200          : 100
HTTP 429          : 9900

The split between 200 and 429 tells you exactly where the throttler kicked in. Here, 100 requests went through before the guard stopped them. Everything after that returned 429.

Every request is also written to responses.log in the same folder. Open it to check what the 429 body actually looks like to the client, and whether the Retry-After header is present and correct.

What a broken rate limiter looks like

If the 200 to 429 split does not match your configured limit, the most likely cause is multiple app instances running without a shared Redis store. Each instance has its own counter, so the effective limit becomes configured limit × number of instances.

If you see 500 errors, the throttler is not intercepting requests before they reach something that cannot handle the load, usually your database or a downstream service. That usually means the limit is set too high for what sits behind it, or the guard registration order is wrong.

If you see all 200s, the guard is simply not running. Double-check that APP_GUARD is registered in providers and that the ThrottlerModule is imported before the guard tries to use it.


Fifty thousand requests on the login endpoint, and the server never flinched. But the people writing those requests were not trying to load-test anything. That is the part rate limiting fixes: it makes sustained abuse expensive enough that it stops being worth it, before your database or your hosting bill notices.


References


Share this post: