← Back to DevBytes

How to Implement Rate Limiting in a Node.js API

What is Rate Limiting?

Rate limiting is a technique used to control the number of requests a client can make to an API within a specified time window. It acts as a gatekeeper that monitors incoming traffic and enforces predefined thresholds. When a client exceeds the allowed number of requests, the API responds with an HTTP 429 (Too Many Requests) status code and typically includes information about when the client can retry.

At its core, rate limiting relies on tracking two pieces of data per client (usually identified by IP address or API key): the number of requests made and the time window in which those requests occurred. Common algorithms include the fixed window counter, sliding window log, sliding window counter, token bucket, and leaky bucket — each with different trade-offs between accuracy, memory usage, and implementation complexity.

Why Rate Limiting Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Implementing rate limiting is not just a nice-to-have; it is a critical defensive layer for any production API. Here are the key reasons:

How to Implement Rate Limiting in a Node.js API

There are several approaches to implementing rate limiting in Node.js, ranging from simple in-memory counters suitable for single-server deployments to distributed Redis-based solutions that work across multiple API instances. Below we cover each approach with complete, production-ready code examples.

Approach 1: Basic In-Memory Rate Limiter (Fixed Window)

The simplest implementation stores request counts in a plain JavaScript Map. This works well for development or single-instance deployments where you don't need persistence across restarts. The following example implements a fixed window counter that resets after each interval.

Create a file called rateLimiter.js:

// rateLimiter.js — Basic in-memory fixed-window rate limiter

const rateLimiter = (maxRequests, windowMs) => {
  // Store: key (client IP or ID) -> { count, resetTime }
  const clients = new Map();

  // Clean up expired entries every 60 seconds to prevent memory leaks
  const cleanupInterval = setInterval(() => {
    const now = Date.now();
    for (const [key, data] of clients.entries()) {
      if (data.resetTime <= now) {
        clients.delete(key);
      }
    }
  }, 60000);

  // Allow the cleanup interval to not block process exit
  if (cleanupInterval.unref) {
    cleanupInterval.unref();
  }

  return (req, res, next) => {
    const clientKey = req.ip || req.headers['x-forwarded-for'] || 'unknown';
    const now = Date.now();
    let clientData = clients.get(clientKey);

    // If no record exists or window has expired, create/reset
    if (!clientData || clientData.resetTime <= now) {
      clientData = {
        count: 1,
        resetTime: now + windowMs,
      };
      clients.set(clientKey, clientData);
      return next();
    }

    // Check if client has exceeded the limit
    if (clientData.count >= maxRequests) {
      const remainingMs = clientData.resetTime - now;
      res.set('Retry-After', Math.ceil(remainingMs / 1000));
      res.set('X-RateLimit-Limit', maxRequests);
      res.set('X-RateLimit-Remaining', 0);
      res.set('X-RateLimit-Reset', Math.ceil(clientData.resetTime / 1000));
      return res.status(429).json({
        error: 'Too Many Requests',
        message: `Rate limit exceeded. Try again in ${Math.ceil(remainingMs / 1000)} seconds.`,
      });
    }

    // Increment count and proceed
    clientData.count++;
    res.set('X-RateLimit-Limit', maxRequests);
    res.set('X-RateLimit-Remaining', maxRequests - clientData.count);
    res.set('X-RateLimit-Reset', Math.ceil(clientData.resetTime / 1000));
    next();
  };
};

module.exports = rateLimiter;

Now integrate it into your Express application:

// app.js — Express application with in-memory rate limiter

const express = require('express');
const rateLimiter = require('./rateLimiter');

const app = express();
const PORT = 3000;

// Apply global rate limit: 100 requests per 15 minutes
app.use(rateLimiter(100, 15 * 60 * 1000));

// Stricter limit on sensitive routes: 5 requests per minute for login
const strictLimiter = rateLimiter(5, 60 * 1000);
app.use('/api/login', strictLimiter);

app.get('/api/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.post('/api/login', (req, res) => {
  // Authentication logic here
  res.json({ message: 'Login endpoint (rate limited)' });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

This implementation works well for single-instance deployments. However, it has limitations: the counter data is lost on server restart, and it does not work across multiple Node.js processes (like when using PM2 cluster mode or Kubernetes pods). For production multi-instance setups, you need a shared data store.

Approach 2: Using express-rate-limit Middleware

The express-rate-limit package is the most popular, battle-tested rate limiting middleware for Express. It handles edge cases like request header spoofing, provides configurable response handling, and supports multiple storage backends out of the box.

Install it first:

npm install express-rate-limit

Basic usage in your Express app:

// app.js — Using express-rate-limit middleware

const express = require('express');
const rateLimit = require('express-rate-limit');

const app = express();

// Global limiter: 200 requests per 15 minutes per IP
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 200,                   // limit each IP to 200 requests per window
  standardHeaders: true,      // Return rate limit info in the RateLimit-* headers
  legacyHeaders: false,       // Disable the X-RateLimit-* headers
  message: {
    error: 'Too Many Requests',
    message: 'You have exceeded the request limit. Please try again later.',
  },
  statusCode: 429,
});

// Auth limiter: 10 requests per minute for login route
const authLimiter = rateLimit({
  windowMs: 60 * 1000,  // 1 minute
  max: 10,
  standardHeaders: true,
  legacyHeaders: false,
  skipSuccessfulRequests: false,  // Count all requests, not just failed ones
  keyGenerator: (req) => {
    // Use both IP and a user identifier if available for finer granularity
    const userKey = req.body?.username || req.ip;
    return `login_${userKey}`;
  },
  handler: (req, res) => {
    res.status(429).json({
      error: 'Too Many Login Attempts',
      message: 'Please wait one minute before trying again.',
      retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),
    });
  },
});

app.use(globalLimiter);
app.use('/api/auth/login', authLimiter);

app.get('/api/data', (req, res) => {
  res.json({ data: 'Some valuable data' });
});

app.post('/api/auth/login', (req, res) => {
  // Authentication logic
  res.json({ token: 'jwt-token-example' });
});

app.listen(3000, () => console.log('Server on port 3000'));

The express-rate-limit package provides several configuration options worth highlighting:

Approach 3: Redis-Based Rate Limiting for Distributed Systems

When your Node.js API runs across multiple instances — behind a load balancer, in a Kubernetes cluster, or using PM2 cluster mode — in-memory rate limiters fail because each instance maintains its own separate counter. Redis provides a centralized, high-performance data store perfect for coordinating rate limiting across all instances.

The sliding window algorithm using sorted sets in Redis is particularly effective. It provides accurate counting without the "burst at boundary" problem of fixed windows. Here is a complete implementation:

Install the required Redis client:

npm install ioredis

Create redisRateLimiter.js:

// redisRateLimiter.js — Sliding window rate limiter using Redis sorted sets

const Redis = require('ioredis');

// Create Redis connection (supports connection string or separate options)
const createRedisClient = (redisUrl = 'redis://localhost:6379') => {
  const client = new Redis(redisUrl, {
    maxRetriesPerRequest: 3,
    retryStrategy(times) {
      if (times > 10) return null; // Stop retrying after 10 attempts
      return Math.min(times * 50, 2000); // Exponential backoff, max 2s
    },
    enableOfflineQueue: false, // Fail fast if Redis is unreachable
  });

  client.on('error', (err) => {
    console.error('Redis connection error:', err.message);
  });

  client.on('ready', () => {
    console.log('Redis rate limiter connected');
  });

  return client;
};

/**
 * Sliding window rate limiter using Redis sorted sets.
 * @param {Object} redisClient - ioredis client instance
 * @param {number} maxRequests - Max requests allowed in the window
 * @param {number} windowMs - Window duration in milliseconds
 * @param {Function} keyGenerator - Function to generate the Redis key from the request
 * @returns {Function} Express middleware
 */
const redisSlidingWindowLimiter = (redisClient, maxRequests, windowMs, keyGenerator) => {
  return async (req, res, next) => {
    try {
      const clientKey = keyGenerator ? keyGenerator(req) : req.ip;
      const redisKey = `rate:${clientKey}`;
      const now = Date.now();
      const windowStart = now - windowMs;

      // Use a Lua script for atomicity: remove expired entries and count remaining
      // This runs atomically on the Redis server, avoiding race conditions
      const luaScript = `
        -- Remove all entries older than the window
        redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
        -- Count the remaining entries (requests in the current window)
        local count = redis.call('ZCARD', KEYS[1])
        -- Add the current request with a unique score (timestamp + microsecond offset)
        -- We add a small random component to handle multiple requests in the same millisecond
        local score = ARGV[2] + (math.random(0, 999) / 1000)
        redis.call('ZADD', KEYS[1], score, ARGV[3])
        -- Set TTL on the key to clean up memory automatically (window + buffer)
        redis.call('PEXPIRE', KEYS[1], ARGV[4])
        return count
      `;

      // Generate a unique member value for this request
      const uniqueMember = `${now}-${Math.random().toString(36).substring(2, 10)}`;

      const result = await redisClient.eval(
        luaScript,
        1,                    // Number of keys
        redisKey,             // KEYS[1]
        windowStart,          // ARGV[1] — cutoff timestamp
        now,                  // ARGV[2] — score (timestamp)
        uniqueMember,         // ARGV[3] — unique member identifier
        windowMs + 1000       // ARGV[4] — TTL in milliseconds (window + 1s buffer)
      );

      const currentCount = result;

      // Set rate limit headers
      const remaining = Math.max(0, maxRequests - currentCount);
      res.set('RateLimit-Limit', maxRequests);
      res.set('RateLimit-Remaining', remaining);
      res.set('RateLimit-Reset', Math.ceil((now + windowMs) / 1000));

      // If the count (before adding the current request) is >= maxRequests, reject
      // Note: we added the current request *after* counting, so count is the number
      // of previous requests in the window. If count >= maxRequests, reject.
      if (currentCount >= maxRequests) {
        // Calculate retry-after
        const oldestRequest = await redisClient.zrange(redisKey, 0, 0, 'WITHSCORES');
        let retryAfterMs = windowMs;
        if (oldestRequest.length >= 2) {
          const oldestScore = parseFloat(oldestRequest[1]);
          retryAfterMs = Math.max(0, Math.ceil(oldestScore + windowMs - now));
        }
        res.set('Retry-After', Math.ceil(retryAfterMs / 1000));
        return res.status(429).json({
          error: 'Too Many Requests',
          message: `Rate limit exceeded. Retry after ${Math.ceil(retryAfterMs / 1000)} seconds.`,
          limit: maxRequests,
          remaining: 0,
        });
      }

      // Update remaining after adding the current request
      res.set('RateLimit-Remaining', Math.max(0, maxRequests - currentCount - 1));
      next();
    } catch (err) {
      console.error('Redis rate limiter error:', err.message);
      // Fail open: allow the request through if Redis is unavailable
      // You may choose to fail closed depending on your security requirements
      next();
    }
  };
};

module.exports = { createRedisClient, redisSlidingWindowLimiter };

Now integrate the Redis rate limiter into your application:

// app.js — Express app with Redis-based distributed rate limiting

const express = require('express');
const { createRedisClient, redisSlidingWindowLimiter } = require('./redisRateLimiter');

const app = express();
const PORT = 3000;

// Initialize Redis client
const redisClient = createRedisClient(process.env.REDIS_URL || 'redis://localhost:6379');

// Global rate limit: 500 requests per 15 minutes per IP
const globalLimiter = redisSlidingWindowLimiter(
  redisClient,
  500,
  15 * 60 * 1000,
  (req) => req.ip
);

// API key-based rate limit: 1000 requests per hour per API key
const apiKeyLimiter = redisSlidingWindowLimiter(
  redisClient,
  1000,
  60 * 60 * 1000,
  (req) => req.headers['x-api-key'] || req.ip  // Fall back to IP if no API key
);

// Sensitive endpoint limiter: 5 requests per minute
const sensitiveLimiter = redisSlidingWindowLimiter(
  redisClient,
  5,
  60 * 1000,
  (req) => `sensitive_${req.ip}`
);

app.use(globalLimiter);
app.use('/api/v1', apiKeyLimiter);
app.use('/api/admin', sensitiveLimiter);

app.get('/api/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.get('/api/v1/users', (req, res) => {
  res.json([{ id: 1, name: 'Alice' }]);
});

app.post('/api/admin/config', (req, res) => {
  res.json({ updated: true });
});

// Graceful shutdown
process.on('SIGTERM', async () => {
  console.log('Shutting down...');
  await redisClient.quit();
  process.exit(0);
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

The Redis-based approach offers several advantages: it works seamlessly across any number of Node.js instances, persists rate limit counters across server restarts, and uses atomic Lua scripts to prevent race conditions. The sorted set implementation provides accurate sliding window behavior — a request made at 12:00:00 and another at 12:14:59 both count against the same 15-minute window, but at 12:15:01 the first request expires precisely when it should.

Approach 4: Token Bucket Algorithm

The token bucket algorithm is widely used by major API providers because it allows for burst traffic while maintaining a steady long-term rate. Each client has a bucket that holds tokens. Tokens are added at a constant rate (the refill rate) up to a maximum capacity (the bucket size). Each request consumes one token. If tokens are available, the request proceeds; if the bucket is empty, the request is rejected. This allows clients to burst up to the bucket capacity while still enforcing a long-term average rate.

Here is a Redis-backed token bucket implementation:

// tokenBucketLimiter.js — Token bucket rate limiter using Redis

const Redis = require('ioredis');

/**
 * Token bucket rate limiter.
 * @param {Object} redisClient - ioredis client
 * @param {number} capacity - Maximum bucket size (burst capacity)
 * @param {number} refillRate - Tokens added per second (sustained rate)
 * @param {Function} keyGenerator - Function to generate client key from request
 * @returns {Function} Express middleware
 */
const tokenBucketLimiter = (redisClient, capacity, refillRate, keyGenerator) => {
  return async (req, res, next) => {
    try {
      const clientKey = keyGenerator ? keyGenerator(req) : req.ip;
      const redisKey = `bucket:${clientKey}`;
      const now = Date.now();

      const luaScript = `
        -- Get current bucket state
        local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last_refill')
        local tokens = tonumber(bucket[1]) or ARGV[1]  -- default to capacity if new
        local lastRefill = tonumber(bucket[2]) or ARGV[2]

        -- Calculate time since last refill and tokens to add
        local elapsedMs = math.max(0, ARGV[2] - lastRefill)
        local tokensToAdd = math.floor(elapsedMs * ARGV[3] / 1000)
        tokens = math.min(ARGV[1], tokens + tokensToAdd)

        local allowed = 0
        local newTokens = tokens
        if tokens >= 1 then
          allowed = 1
          newTokens = tokens - 1
        end

        -- Update bucket state
        redis.call('HSET', KEYS[1], 'tokens', newTokens, 'last_refill', ARGV[2])
        -- Set TTL to clean up inactive buckets (capacity / refillRate * 2 + 60s buffer)
        local ttlSeconds = math.ceil((ARGV[1] / ARGV[3]) * 2) + 60
        redis.call('EXPIRE', KEYS[1], ttlSeconds)

        return {allowed, newTokens}
      `;

      const result = await redisClient.eval(
        luaScript,
        1,
        redisKey,
        capacity,       // ARGV[1] — bucket capacity
        now,            // ARGV[2] — current timestamp
        refillRate      // ARGV[3] — tokens per second refill rate
      );

      // result is [allowed, newTokens] — Lua table converted to array
      const allowed = result[0];
      const tokensLeft = result[1];

      res.set('X-RateLimit-Limit', capacity);
      res.set('X-RateLimit-Remaining', Math.floor(tokensLeft));
      res.set('X-RateLimit-Bucket-Capacity', capacity);
      res.set('X-RateLimit-Refill-Rate', refillRate);

      if (!allowed) {
        // Calculate time until next token is available
        const secondsUntilToken = Math.ceil(1 / refillRate);
        res.set('Retry-After', secondsUntilToken);
        return res.status(429).json({
          error: 'Too Many Requests',
          message: `Rate limit exceeded. Retry after ${secondsUntilToken} seconds.`,
          bucketCapacity: capacity,
          tokensRemaining: 0,
        });
      }

      res.set('X-RateLimit-Remaining', Math.floor(tokensLeft));
      next();
    } catch (err) {
      console.error('Token bucket error:', err.message);
      next(); // Fail open
    }
  };
};

module.exports = tokenBucketLimiter;

Using the token bucket limiter in your app:

const express = require('express');
const Redis = require('ioredis');
const tokenBucketLimiter = require('./tokenBucketLimiter');

const app = express();
const redis = new Redis('redis://localhost:6379');

// Allow bursts of up to 50 requests, with a sustained rate of 10 requests per second
const apiLimiter = tokenBucketLimiter(
  redis,
  50,   // bucket capacity (burst size)
  10,   // refill rate (tokens per second)
  (req) => req.headers['x-api-key'] || req.ip
);

app.use('/api/', apiLimiter);

app.get('/api/data', (req, res) => {
  res.json({ message: 'Token bucket rate limited endpoint' });
});

app.listen(3000);

The token bucket is ideal when your API clients have legitimate burst patterns — for example, a mobile app that makes several requests on startup but then idles. The bucket capacity allows that burst to succeed while the refill rate ensures long-term average usage stays within limits.

Rate Limiting Best Practices

Implementing rate limiting effectively requires more than just dropping in middleware. Consider these best practices to build a robust, user-friendly system:

Conclusion

Rate limiting is an essential defensive layer for any Node.js API, protecting against abuse, ensuring fair resource allocation, and maintaining system stability. Whether you start with a simple in-memory counter for a small project, adopt the robust express-rate-limit middleware for a production single-instance deployment, or implement a Redis-backed sliding window or token bucket for a distributed microservice architecture, the core principle remains the same: measure, enforce, and communicate limits clearly. By following the implementation patterns and best practices outlined in this tutorial, you can build a rate limiting system that is accurate, performant, and respectful of legitimate API consumers while keeping malicious traffic at bay.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles