← Back to Tutorials
Node.js

Build a Rate Limiter for an API

Difficulty: Intermediate Est. Time: ~3 hours

Introduction

Rate limiting is essential for API security. It protects your services from abuse, prevents server overload, and ensures fair usage among clients.

In this tutorial, we'll build a rate limiter from scratch using various algorithms and implement it in an Express.js API.

What You'll Build
  • A custom rate limiter middleware
  • Token bucket algorithm implementation
  • Sliding window rate limiter
  • Redis-based distributed rate limiting
  • Multiple rate limit strategies
What You'll Learn
  • Rate limiting algorithms
  • Express middleware development
  • Redis for distributed systems
  • API security best practices
  • IP-based and user-based limiting

Project Overview

Our rate limiter will support multiple strategies and configurations:

Features

  • Token Bucket - Smooth rate limiting
  • Sliding Window - Precise limiting
  • Fixed Window - Simple and fast
  • Redis Backend - Distributed limiting
  • Custom Rules - Per-IP, per-user limits

Prerequisites

  • Node.js installed - Version 14+
  • Express.js - Basic knowledge
  • Redis - For distributed limiting (optional)
  • JavaScript - ES6+ syntax

Project Setup

Bash
# Create project directory
mkdir rate-limiter
cd rate-limiter

# Initialize Node.js
npm init -y

# Install dependencies
npm install express ioredis express-rate-limit

# Install for demo
npm install cors

Project Structure

File Structure
rate-limiter/
├── middleware/
│   ├── rateLimiter.js
│   ├── tokenBucket.js
│   └── slidingWindow.js
├── stores/
│   ├── memoryStore.js
│   └── redisStore.js
├── server.js
└── package.json

Token Bucket Algorithm

The token bucket algorithm allows bursts while maintaining an average rate. Tokens are added to a bucket at a fixed rate, and each request consumes tokens.

JavaScript
// middleware/tokenBucket.js
class TokenBucket {
    constructor(capacity, refillRate) {
        this.capacity = capacity;
        this.refillRate = refillRate;
        this.buckets = new Map();
        
        // Start automatic refill
        setInterval(() => this.refillAll(), 1000);
    }

    consume(key) {
        let bucket = this.buckets.get(key);
        
        if (!bucket) {
            bucket = {
                tokens: this.capacity,
                lastRefill: Date.now()
            };
            this.buckets.set(key, bucket);
        }
        
        this.refill(bucket);
        
        if (bucket.tokens >= 1) {
            bucket.tokens -= 1;
            return {
                allowed: true,
                remainingTokens: Math.floor(bucket.tokens)
            };
        }
        
        return {
            allowed: false,
            remainingTokens: 0,
            retryAfter: Math.ceil((1 - bucket.tokens) / this.refillRate)
        };
    }

    refill(bucket) {
        const now = Date.now();
        const timePassed = (now - bucket.lastRefill) / 1000;
        const tokensToAdd = timePassed * this.refillRate;
        
        bucket.tokens = Math.min(this.capacity, bucket.tokens + tokensToAdd);
        bucket.lastRefill = now;
    }

    refillAll() {
        for (const bucket of this.buckets.values()) {
            this.refill(bucket);
        }
    }

    reset(key) {
        this.buckets.delete(key);
    }

    getStatus(key) {
        let bucket = this.buckets.get(key);
        
        if (!bucket) {
            return {
                tokens: this.capacity,
                capacity: this.capacity,
                refillRate: this.refillRate
            };
        }
        
        this.refill(bucket);
        
        return {
            tokens: Math.floor(bucket.tokens),
            capacity: this.capacity,
            refillRate: this.refillRate
        };
    }
}

module.exports = TokenBucket;
Token Bucket Explained
  • Capacity - Maximum tokens in the bucket
  • Refill Rate - Tokens added per second
  • Consume - Request consumes 1 token
  • Burst - Up to capacity requests at once

Basic Rate Limiter

Let's create a basic in-memory rate limiter middleware.

JavaScript
// middleware/rateLimiter.js
const TokenBucket = require('./tokenBucket');

class RateLimiter {
    constructor(options = {}) {
        this.windowSize = options.windowSize || 60000;
        this.maxRequests = options.maxRequests || 100;
        this.keyGenerator = options.keyGenerator || this.defaultKeyGenerator;
        this.handler = options.handler || this.defaultHandler;
        
        // Store for tracking requests
        this.store = new Map();
        
        // Cleanup old entries periodically
        setInterval(() => this.cleanup(), this.windowSize);
    }

    defaultKeyGenerator(req) {
        return req.ip || req.connection.remoteAddress;
    }

    defaultHandler(req, res) {
        res.status(429).json({
            error: 'Too Many Requests',
            message: 'Rate limit exceeded. Please try again later.'
        });
    }

    isAllowed(key) {
        const now = Date.now();
        const windowStart = now - this.windowSize;
        
        if (!this.store.has(key)) {
            this.store.set(key, []);
        }
        
        const requests = this.store.get(key);
        
        // Remove old requests outside the window
        const validRequests = requests.filter(time => time > windowStart);
        this.store.set(key, validRequests);
        
        if (validRequests.length >= this.maxRequests) {
            const oldestRequest = validRequests[0];
            const retryAfter = Math.ceil((oldestRequest + this.windowSize - now) / 1000);
            
            return {
                allowed: false,
                remaining: 0,
                resetTime: oldestRequest + this.windowSize,
                retryAfter
            };
        }
        
        validRequests.push(now);
        return {
            allowed: true,
            remaining: this.maxRequests - validRequests.length,
            resetTime: now + this.windowSize
        };
    }

    middleware() {
        return (req, res, next) => {
            const key = this.keyGenerator(req);
            const result = this.isAllowed(key);
            
            // Set rate limit headers
            res.set('X-RateLimit-Limit', this.maxRequests);
            res.set('X-RateLimit-Remaining', result.remaining);
            res.set('X-RateLimit-Reset', Math.ceil(result.resetTime / 1000));
            
            if (!result.allowed) {
                res.set('Retry-After', result.retryAfter);
                return this.handler(req, res);
            }
            
            next();
        };
    }

    cleanup() {
        const now = Date.now();
        const windowStart = now - this.windowSize;
        
        for (const [key, requests] of this.store.entries()) {
            const validRequests = requests.filter(time => time > windowStart);
            
            if (validRequests.length === 0) {
                this.store.delete(key);
            } else {
                this.store.set(key, validRequests);
            }
        }
    }
}

module.exports = RateLimiter;

Distributed Rate Limiting

For multi-server deployments, we need Redis-based rate limiting.

JavaScript
// stores/redisStore.js
const Redis = require('ioredis');

class RedisStore {
    constructor(options = {}) {
        this.redis = new Redis(options.redis || {
            host: options.host || 'localhost',
            port: options.port || 6379
        });
        
        this.prefix = options.prefix || 'ratelimit:';
    }

    _getKey(key) {
        return this.prefix + key;
    }

    increment(key, windowSize) {
        const redisKey = this._getKey(key);
        
        return this.redis
            .multi()
            .incr(redisKey)
            .pttl(redisKey)
            .exec()
            .then(([[, count], [, ttl]]) => {
                // Set expiry on first request
                if (ttl === -1) {
                    return this.redis.pexpire(redisKey, windowSize)
                        .then(() => ({ count: 1, ttl: windowSize }));
                }
                return { count, ttl };
            });
    }

    decrement(key) {
        const redisKey = this._getKey(key);
        return this.redis.decr(redisKey);
    }

    reset(key) {
        const redisKey = this._getKey(key);
        return this.redis.del(redisKey);
    }

    getStatus(key, maxRequests, windowSize) {
        const redisKey = this._getKey(key);
        
        return this.redis
            .pipeline()
            .get(redisKey)
            .pttl(redisKey)
            .exec()
            .then(([[, count], [, ttl]]) => {
                const currentCount = parseInt(count) || 0;
                const remaining = Math.max(0, maxRequests - currentCount);
                const resetTime = ttl > 0 ? Date.now() + ttl : Date.now() + windowSize;
                
                return {
                    allowed: currentCount < maxRequests,
                    remaining,
                    resetTime,
                    ttl
                };
            });
    }

    close() {
        return this.redis.quit();
    }
}

// Distributed rate limiter using Redis
class DistributedRateLimiter {
    constructor(options = {}) {
        this.store = options.store || new RedisStore(options);
        this.maxRequests = options.maxRequests || 100;
        this.windowSize = options.windowSize || 60000;
        this.keyGenerator = options.keyGenerator || this.defaultKeyGenerator;
    }

    defaultKeyGenerator(req) {
        return req.ip || req.connection.remoteAddress;
    }

    middleware() {
        return async (req, res, next) => {
            const key = this.keyGenerator(req);
            
            try {
                const result = await this.store.increment(key, this.windowSize);
                
                res.set('X-RateLimit-Limit', this.maxRequests);
                res.set('X-RateLimit-Remaining', 
                    Math.max(0, this.maxRequests - result.count));
                res.set('X-RateLimit-Reset', 
                    Math.ceil((Date.now() + result.ttl) / 1000));
                
                if (result.count > this.maxRequests) {
                    return res.status(429).json({
                        error: 'Too Many Requests',
                        retryAfter: Math.ceil(result.ttl / 1000)
                    });
                }
                
                next();
            } catch (error) {
                console.error('Rate limiter error:', error);
                next();
            }
        };
    }
}

module.exports = { RedisStore, DistributedRateLimiter };

Express Integration

Let's integrate the rate limiter with Express and create different limiting strategies.

JavaScript
// server.js
const express = require('express');
const cors = require('cors');
const RateLimiter = require('./middleware/rateLimiter');
const { DistributedRateLimiter } = require('./stores/redisStore');

const app = express();
app.use(cors());
app.use(express.json());

// Create different rate limiters for different use cases

// 1. Global rate limiter - 100 requests per minute
const globalLimiter = new RateLimiter({
    windowSize: 60 * 1000,
    maxRequests: 100
});

// 2. Stricter limiter for auth endpoints - 5 requests per minute
const authLimiter = new RateLimiter({
    windowSize: 60 * 1000,
    maxRequests: 5,
    keyGenerator: (req) => `auth:${req.ip}`
});

// 3. Generous limiter for premium users - 1000 requests per minute
const premiumLimiter = new RateLimiter({
    windowSize: 60 * 1000,
    maxRequests: 1000
});

// 4. Distributed rate limiter (requires Redis)
// const distributedLimiter = new DistributedRateLimiter({
//     maxRequests: 100,
//     windowSize: 60 * 1000
// });

// Apply global rate limiter to all routes
app.use(globalLimiter.middleware());

// API routes
app.get('/api/public', (req, res) => {
    res.json({ message: 'Public endpoint - global rate limit applies' });
});

// Auth routes with stricter limiting
app.post('/api/auth/login', authLimiter.middleware(), (req, res) => {
    res.json({ message: 'Login attempt - stricter rate limit' });
});

app.post('/api/auth/register', authLimiter.middleware(), (req, res) => {
    res.json({ message: 'Register attempt - stricter rate limit' });
});

// Premium endpoint with generous limiting
app.get('/api/premium/data', premiumLimiter.middleware(), (req, res) => {
    res.json({ message: 'Premium endpoint - generous rate limit' });
});

// Example: Skip rate limiting for whitelisted IPs
const whitelist = ['127.0.0.1', '::1'];

app.get('/api/whitelist-test', (req, res, next) => {
    if (whitelist.includes(req.ip)) {
        return next();
    }
    return globalLimiter.middleware()(req, res, next);
}, (req, res) => {
    res.json({ message: 'Whitelisted or rate limited' });
});

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

// Status endpoint to see rate limit info
app.get('/api/status', (req, res) => {
    const key = globalLimiter.keyGenerator(req);
    const status = globalLimiter.isAllowed(key);
    res.json({
        key,
        ...status,
        limit: globalLimiter.maxRequests
    });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
});
Rate Limit Headers
  • X-RateLimit-Limit - Maximum requests allowed
  • X-RateLimit-Remaining - Remaining requests
  • X-RateLimit-Reset - Unix timestamp when limit resets
  • Retry-After - Seconds to wait before retrying

Custom Strategies

Let's create custom rate limiting strategies for different scenarios.

JavaScript
// Advanced rate limiting strategies

// 1. Leaky Bucket Algorithm
class LeakyBucket {
    constructor(capacity, leakRate) {
        this.capacity = capacity;
        this.leakRate = leakRate;
        this.level = 0;
        this.lastLeak = Date.now();
        
        setInterval(() => this.leak(), 1000);
    }

    leak() {
        const now = Date.now();
        const timePassed = (now - this.lastLeak) / 1000;
        const amountLeaked = timePassed * this.leakRate;
        
        this.level = Math.max(0, this.level - amountLeaked);
        this.lastLeak = now;
    }

    add(amount = 1) {
        this.leak();
        
        if (this.level + amount <= this.capacity) {
            this.level += amount;
            return { allowed: true, level: this.level };
        }
        
        return { 
            allowed: false, 
            level: this.level,
            retryAfter: Math.ceil((this.level + amount - this.capacity) / this.leakRate)
        };
    }
}

// 2. Sliding Window Rate Limiter
class SlidingWindowRateLimiter {
    constructor(maxRequests, windowSize) {
        this.maxRequests = maxRequests;
        this.windowSize = windowSize;
        this.requests = new Map();
    }

    isAllowed(key) {
        const now = Date.now();
        const windowStart = now - this.windowSize;
        
        if (!this.requests.has(key)) {
            this.requests.set(key, []);
        }
        
        const timestamps = this.requests.get(key);
        
        // Remove old timestamps
        const validTimestamps = timestamps.filter(ts => ts > windowStart);
        
        if (validTimestamps.length >= this.maxRequests) {
            const oldestValid = validTimestamps[0];
            return {
                allowed: false,
                remaining: 0,
                retryAfter: Math.ceil((oldestValid + this.windowSize - now) / 1000)
            };
        }
        
        validTimestamps.push(now);
        this.requests.set(key, validTimestamps);
        
        return {
            allowed: true,
            remaining: this.maxRequests - validTimestamps.length
        };
    }
}

// 3. Adaptive Rate Limiter
class AdaptiveRateLimiter {
    constructor(baseLimit = 100) {
        this.baseLimit = baseLimit;
        this.errorCounts = new Map();
        this.successCounts = new Map();
    }

    getLimit(key) {
        const errors = this.errorCounts.get(key) || 0;
        const successes = this.successCounts.get(key) || 0;
        
        // Decrease limit if many errors
        if (errors > 10) {
            return Math.max(10, this.baseLimit * 0.1);
        }
        
        // Increase limit if mostly successful
        if (successes > 100 && errors < 5) {
            return Math.min(1000, this.baseLimit * 1.5);
        }
        
        return this.baseLimit;
    }

    recordSuccess(key) {
        const current = this.successCounts.get(key) || 0;
        this.successCounts.set(key, current + 1);
    }

    recordError(key) {
        const current = this.errorCounts.get(key) || 0;
        this.errorCounts.set(key, current + 1);
    }
}

Testing

Bash
# Start the server
node server.js

# Test rate limiting

# Make multiple requests (should hit limit after 100)
for i in {1..105}; do
    curl -s -o /dev/null -w "Request $i: %{http_code}\n" http://localhost:3000/api/public
done

# Check rate limit headers
curl -I http://localhost:3000/api/public

# Test status endpoint
curl http://localhost:3000/api/status

# Test stricter auth limits (5 requests)
for i in {1..7}; do
    curl -X POST http://localhost:3000/api/auth/login \
      -H "Content-Type: application/json" \
      -d '{}' \
      -w "Request $i: %{http_code}\n"
done
Testing Checklist
  • ✓ Rate limiting applies after threshold
  • ✓ Correct headers are set
  • ✓ Different limits for different routes
  • ✓ Custom key generators work
  • ✓ Redis distributed limiting works

Summary

Congratulations! You've built a comprehensive rate limiting system.

What You Built

  • Token Bucket - Burst-friendly limiting
  • Leaky Bucket - Smooth outflow limiting
  • Sliding Window - Precise time-based limiting
  • Redis Store - Distributed rate limiting
  • Adaptive Limiter - Dynamic limits based on behavior

Next Steps

  • Add caching layer
  • Implement request queuing
  • Add dashboard for monitoring
  • Integrate with API gateway

Continue Learning

Try these tutorials:

  • Build a REST API with JWT
  • Build a Real-Time Notification System
  • Build a Todo App with Authentication