Every public API eventually meets a client that sends too many requests. Sometimes it is a bot hammering your login route, sometimes a buggy frontend stuck in a retry loop, and sometimes a single user with a script. Without rate limiting, one noisy client can slow down or take down the API for everyone else.

In this guide you will add rate limiting to a Node.js and Express API using Redis. We start with the quickest production-ready setup, then build our own fixed window and sliding window limiters so you understand exactly what happens under the hood. If you are new to Node.js, read What is Node.js? Complete Beginner's Guide first.

What Is API Rate Limiting?

Rate limiting caps how many requests a client can make in a given time window, for example 100 requests per 15 minutes per IP address. When a client goes over the limit, the server stops processing its requests and answers with HTTP status 429 Too Many Requests until the window allows more traffic.

Rate limiting helps you:

  • Stop brute-force attacks on login, OTP and password reset routes.
  • Protect expensive endpoints such as search, file uploads, or AI calls that cost real money.
  • Keep the API fair so one client cannot use up resources everyone shares.
  • Absorb traffic spikes from scrapers and misbehaving integrations.

Why Use Redis for Rate Limiting?

The simplest limiter keeps a counter in memory inside your Node.js process. That works on one server, but it breaks as soon as you scale:

  • With 3 instances behind a load balancer, each instance keeps its own counter, so a client effectively gets 3x the limit.
  • Every deploy or crash resets the counters to zero.
  • Serverless functions do not keep memory between invocations at all.

Redis solves this. It is one shared, in-memory store that all instances talk to. Its commands like INCR are atomic, and keys can expire automatically with a TTL. That is the same reason Redis works so well for short-lived data such as OTP codes, which I covered in Redis vs Database for OTP Storage.

Project Setup

Create a new project and install Express plus a Redis client. You also need a running Redis server. Locally, the quickest way is Docker.

mkdir rate-limit-demo && cd rate-limit-demo
npm init -y
npm install express ioredis redis express-rate-limit rate-limit-redis

# start Redis locally
docker run -d --name redis -p 6379:6379 redis:7

Add "type": "module" to your package.json so you can use import syntax.

Option 1: express-rate-limit with a Redis Store

For most projects, this is all you need. express-rate-limit handles the middleware logic, and rate-limit-redis stores the counters in Redis so every instance shares them.

import express from 'express'
import { createClient } from 'redis'
import { rateLimit } from 'express-rate-limit'
import { RedisStore } from 'rate-limit-redis'

const client = createClient({ url: process.env.REDIS_URL ?? 'redis://localhost:6379' })
await client.connect()

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  limit: 100,               // 100 requests per window per IP
  standardHeaders: 'draft-7',
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args) => client.sendCommand(args),
  }),
})

const app = express()
app.use('/api', apiLimiter)

app.get('/api/hello', (req, res) => res.json({ message: 'Hello!' }))

app.listen(3000, () => console.log('API running on http://localhost:3000'))

When a client goes over 100 requests, it gets a 429 response along with standard RateLimit headers that tell it how long to wait. That is it: you now have a distributed rate limiter.

Option 2: Build a Fixed Window Rate Limiter

To really understand rate limiting, let's build one ourselves. The fixed window algorithm is the simplest:

  1. Build a key from the client and the current window, for example rl:203.0.113.7:28467312.
  2. Increment the key with INCR.
  3. If it is the first request in the window, set a TTL so the key cleans itself up.
  4. If the count is above the limit, return 429.

Running INCR and EXPIRE as two separate calls has a subtle bug: if your process crashes between them, the key never expires and the client is blocked forever. A small Lua script runs both commands atomically inside Redis:

// fixedWindow.js
import Redis from 'ioredis'

const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379')

redis.defineCommand('fixedWindow', {
  numberOfKeys: 1,
  lua: `
    local count = redis.call('INCR', KEYS[1])
    if count == 1 then
      redis.call('EXPIRE', KEYS[1], ARGV[1])
    end
    return count
  `,
})

export function fixedWindowLimiter({ limit = 100, windowSec = 60 } = {}) {
  return async (req, res, next) => {
    const window = Math.floor(Date.now() / 1000 / windowSec)
    const key = `rl:fixed:${req.ip}:${window}`

    try {
      const count = await redis.fixedWindow(key, windowSec)
      const resetSec = windowSec - (Math.floor(Date.now() / 1000) % windowSec)

      res.set('RateLimit-Limit', String(limit))
      res.set('RateLimit-Remaining', String(Math.max(0, limit - count)))
      res.set('RateLimit-Reset', String(resetSec))

      if (count > limit) {
        res.set('Retry-After', String(resetSec))
        return res.status(429).json({ message: 'Too many requests, please try again later.' })
      }
      next()
    } catch (err) {
      // Fail open: if Redis is down, don't take the whole API down with it
      console.error('Rate limiter error:', err)
      next()
    }
  }
}

This is fast, needing only one Redis round trip per request, and uses very little memory. But it has a well-known weakness.

The Problem with Fixed Windows

Counters reset at the window boundary. A client can send the full limit at the very end of one window and the full limit again at the start of the next, which means double the allowed traffic in a short burst.

Fixed window vs sliding window rate limiting: with a limit of 5 requests per 60 seconds, a fixed window lets 10 requests through around the boundary while a sliding window blocks the extra 5 with HTTP 429

For a public read endpoint, this is usually fine. For a login route, it means an attacker gets twice as many password guesses. That is where the sliding window comes in.

Option 3: Sliding Window Rate Limiter with Redis Sorted Sets

The sliding window log algorithm stores a timestamp for every request in a Redis sorted set. For each new request we:

  1. Remove timestamps older than the window with ZREMRANGEBYSCORE.
  2. Count what is left with ZCARD.
  3. If we are under the limit, add the current request with ZADD. Otherwise, reject it.

Again, we wrap it in Lua so that check-then-add is atomic. Without that, two parallel requests could both see "4 of 5" and both get through.

// slidingWindow.js
import Redis from 'ioredis'
import { randomUUID } from 'node:crypto'

const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379')

redis.defineCommand('slidingWindow', {
  numberOfKeys: 1,
  lua: `
    local key    = KEYS[1]
    local now    = tonumber(ARGV[1])
    local window = tonumber(ARGV[2])
    local limit  = tonumber(ARGV[3])

    redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
    local count = redis.call('ZCARD', key)

    if count < limit then
      redis.call('ZADD', key, now, ARGV[4])
      redis.call('PEXPIRE', key, window)
      return {1, limit - count - 1, 0}
    end

    local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
    return {0, 0, tonumber(oldest[2]) + window - now}
  `,
})

export function slidingWindowLimiter({ limit = 5, windowMs = 60_000, keyPrefix = 'rl:sliding' } = {}) {
  return async (req, res, next) => {
    const key = `${keyPrefix}:${req.ip}`

    try {
      const [allowed, remaining, retryAfterMs] = await redis.slidingWindow(
        key, Date.now(), windowMs, limit, randomUUID()
      )

      res.set('RateLimit-Limit', String(limit))
      res.set('RateLimit-Remaining', String(remaining))

      if (!allowed) {
        res.set('Retry-After', String(Math.ceil(retryAfterMs / 1000)))
        return res.status(429).json({ message: 'Too many requests, please try again later.' })
      }
      next()
    } catch (err) {
      console.error('Rate limiter error:', err)
      next()
    }
  }
}

Now apply a strict limit to sensitive routes and a relaxed one everywhere else:

import express from 'express'
import { fixedWindowLimiter } from './fixedWindow.js'
import { slidingWindowLimiter } from './slidingWindow.js'

const app = express()
app.set('trust proxy', 1) // required behind Nginx, Vercel, Render, etc.
app.use(express.json())

// Generous limit for the whole API
app.use('/api', fixedWindowLimiter({ limit: 300, windowSec: 60 }))

// Strict limit for login: 5 attempts per minute
app.post(
  '/api/auth/login',
  slidingWindowLimiter({ limit: 5, windowMs: 60_000, keyPrefix: 'rl:login' }),
  (req, res) => res.json({ ok: true })
)

app.listen(3000)

The sliding window is accurate, with no boundary bursts, but it stores one entry per request, so it uses more memory. Use it for low-limit, high-value routes and keep the fixed window for high-volume traffic.

Fixed Window vs Sliding Window: Which Should You Use?

  • Fixed window: 1 counter per client, very fast and cheap. Allows bursts of up to 2x the limit at window edges. Best for general API traffic.
  • Sliding window log: exact limits, no bursts. Memory grows with the limit, so it is best for small limits on login, OTP, signup and password reset.
  • Token bucket: allows controlled bursts while keeping an average rate. Great for public developer APIs, and a good next algorithm to explore.

Testing Your Rate Limiter

Fire 7 requests at the login route and watch the last two fail:

for i in 1 2 3 4 5 6 7; do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/auth/login
done

# 200 200 200 200 200 429 429

You can also look at the keys directly with redis-cli: run KEYS rl:* to find your keys, then ZRANGE ZRANGE rl:login:::1 0 -1 WITHSCORES shows every timestamp in the current window.lt;keyZRANGE rl:login:::1 0 -1 WITHSCORES shows every timestamp in the current window.gt; 0 -1 WITHSCORES to see every request timestamp in the current window.

Production Best Practices

  • Set trust proxy correctly. Behind a reverse proxy, req.ip is the proxy's IP unless app.set('trust proxy', 1) is configured, and every user ends up sharing one limit.
  • Limit by user, not just IP. For authenticated routes, use the user ID or API key as the identifier. Many users can share one IP on mobile networks and offices.
  • Decide fail-open vs fail-closed. The examples above fail open so a Redis outage does not take down the API. For login and OTP routes, you might prefer to fail closed.
  • Always send Retry-After so well-behaved clients know when to retry instead of hammering you.
  • Prefix your keys (rl:login:, rl:api:) so limits for different routes never collide.
  • Add a limit at the edge too. Nginx, Cloudflare or your API gateway can drop obvious floods before they ever reach Node.js.

Frequently Asked Questions

What HTTP status code should a rate limiter return?

Return 429 Too Many Requests, ideally with a Retry-After header that tells the client how many seconds to wait before trying again.

Can I do rate limiting in Node.js without Redis?

Yes. express-rate-limit uses an in-memory store by default, which is fine for a single server. Once you run more than one instance or deploy to serverless, you need a shared store like Redis so all instances see the same counts.

Is the fixed window or the sliding window algorithm better?

Neither is strictly better. Fixed window is cheaper and fine for general traffic. Sliding window is exact and better for sensitive routes like login and OTP verification where bursts matter.

Does rate limiting stop DDoS attacks?

Not on its own. Application-level rate limiting protects against abuse from individual clients, but a large distributed attack should be stopped earlier, at a CDN, WAF or load balancer, before it reaches your server.

Conclusion

Rate limiting is one of the cheapest and most effective ways to protect a Node.js API. Start with express-rate-limit and a Redis store for most routes, then add a sliding window limiter to sensitive endpoints like login and OTP. With Redis behind it, the limits hold no matter how many instances you run.

If you are building AI-powered backends, rate limiting matters even more, because every request can cost money. Check out How to Build an MCP Server with Node.js and TypeScript next.