js

Build a Distributed Rate Limiter with Redis, Node.js and TypeScript: Complete Tutorial

Learn to build a scalable distributed rate limiter with Redis, Node.js & TypeScript. Master algorithms, clustering, monitoring & production deployment strategies.

Build a Distributed Rate Limiter with Redis, Node.js and TypeScript: Complete Tutorial

I’ve always been fascinated by how modern web applications handle massive traffic without buckling under pressure. Recently, while scaling a Node.js API that serves millions of users, I hit a wall with rate limiting. Our in-memory solution worked fine for a single server, but as we added more instances, clients could bypass limits by hitting different servers. This experience drove me to build a distributed rate limiter using Redis, Node.js, and TypeScript—a solution that scales seamlessly across multiple application instances.

Why Redis? It provides a shared state that all our application servers can access simultaneously. Imagine trying to coordinate traffic rules across multiple intersections without a central system—chaos would ensue. Redis acts as that central traffic controller, ensuring every request is counted consistently regardless of which server handles it.

Let me show you the core problem with in-memory rate limiting:

class LocalRateLimiter {
  private requests = new Map<string, number[]>();
  
  isAllowed(clientId: string): boolean {
    const now = Date.now();
    const clientRequests = this.requests.get(clientId) || [];
    const recentRequests = clientRequests.filter(time => now - time < 60000);
    
    if (recentRequests.length >= 100) return false;
    
    recentRequests.push(now);
    this.requests.set(clientId, recentRequests);
    return true;
  }
}

This works perfectly for one server, but what happens when you have ten servers? Each maintains its own count, allowing clients to make 100 requests to each server—effectively bypassing the limit. That’s where Redis changes the game.

Have you ever wondered how popular APIs like Twitter or GitHub enforce strict rate limits? They use distributed systems similar to what we’re building. The secret lies in choosing the right algorithm. Let’s explore three common approaches.

The fixed window algorithm divides time into distinct intervals. If you set a limit of 100 requests per minute, it resets exactly on the minute mark. Simple, but it can allow bursts at window boundaries.

async function fixedWindowCheck(key: string): Promise<boolean> {
  const windowSize = 60000;
  const limit = 100;
  const now = Date.now();
  const windowStart = Math.floor(now / windowSize) * windowSize;
  const redisKey = `rate_limit:${key}:${windowStart}`;
  
  const current = await redis.incr(redisKey);
  if (current === 1) await redis.pexpire(redisKey, windowSize);
  
  return current <= limit;
}

The sliding window log maintains a timestamp for each request, giving more accurate limiting but using more memory. The sliding window counter balances accuracy and efficiency by combining fixed windows.

Which algorithm should you choose? It depends on your precision needs and resource constraints. For most APIs, I find the sliding window counter offers the best trade-off.

Now, let’s build the actual rate limiter. We’ll use TypeScript for type safety and better developer experience. Here’s a basic implementation:

interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetTime: number;
}

class DistributedRateLimiter {
  constructor(private redis: Redis) {}

  async checkLimit(
    key: string, 
    windowMs: number, 
    maxRequests: number
  ): Promise<RateLimitResult> {
    const now = Date.now();
    const windowStart = Math.floor(now / windowMs) * windowMs;
    
    const luaScript = `
      local key = KEYS[1]
      local window_start = ARGV[1]
      local max_requests = tonumber(ARGV[2])
      local window_ms = tonumber(ARGV[3])
      
      local current = redis.call('GET', key)
      if not current then
        current = 0
      else
        current = tonumber(current)
      end
      
      if current >= max_requests then
        return {0, current, window_start + window_ms}
      end
      
      local new_count = redis.call('INCR', key)
      if new_count == 1 then
        redis.call('PEXPIRE', key, window_ms)
      end
      
      return {1, new_count, window_start + window_ms}
    `;
    
    const result = await this.redis.eval(
      luaScript, 
      1, 
      `rate_limit:${key}:${windowStart}`, 
      windowStart.toString(),
      maxRequests.toString(),
      windowMs.toString()
    ) as [number, number, number];
    
    return {
      allowed: result[0] === 1,
      remaining: Math.max(0, maxRequests - result[1]),
      resetTime: result[2]
    };
  }
}

Notice I used a Lua script? This ensures our Redis operations are atomic—critical for accurate counting in high-concurrency environments. Without atomic operations, race conditions could let through extra requests.

How do we integrate this into an Express application? Middleware makes it elegant and reusable:

function rateLimitMiddleware(
  config: { windowMs: number; maxRequests: number }
) {
  const limiter = new DistributedRateLimiter(redis);
  
  return async (req: Request, res: Response, next: NextFunction) => {
    const key = req.ip; // Or use API keys, user IDs, etc.
    const result = await limiter.checkLimit(
      key, 
      config.windowMs, 
      config.maxRequests
    );
    
    if (!result.allowed) {
      res.setHeader('X-RateLimit-Limit', config.maxRequests);
      res.setHeader('X-RateLimit-Remaining', result.remaining);
      res.setHeader('X-RateLimit-Reset', result.resetTime);
      return res.status(429).json({ error: 'Too many requests' });
    }
    
    res.setHeader('X-RateLimit-Limit', config.maxRequests);
    res.setHeader('X-RateLimit-Remaining', result.remaining);
    res.setHeader('X-RateLimit-Reset', result.resetTime);
    next();
  };
}

What about Redis clustering? As your application grows, a single Redis instance might become a bottleneck. Redis Cluster distributes data across multiple nodes, providing horizontal scalability. Here’s how to set it up with ioredis:

import { Cluster } from 'ioredis';

const redisCluster = new Cluster([
  { host: 'redis-node-1', port: 6379 },
  { host: 'redis-node-2', port: 6379 },
  { host: 'redis-node-3', port: 6379 }
]);

const clusterLimiter = new DistributedRateLimiter(redisCluster);

Monitoring is crucial for production systems. I always add metrics to track rate limit hits and misses:

async function checkLimitWithMetrics(
  key: string, 
  windowMs: number, 
  maxRequests: number
): Promise<RateLimitResult> {
  const result = await limiter.checkLimit(key, windowMs, maxRequests);
  
  // Send metrics to your monitoring system
  metrics.increment('rate_limit.checks', { key, allowed: result.allowed });
  if (!result.allowed) {
    metrics.increment('rate_limit.violations', { key });
  }
  
  return result;
}

In production, consider fallback strategies. What if Redis goes down? You might implement a circuit breaker that fails open (allowing all requests) or closed (blocking all requests) based on your security requirements.

Did you know that proper rate limiting can also improve your application’s security? It helps mitigate denial-of-service attacks and API abuse while ensuring fair usage among customers.

Deploying this solution requires careful planning. Use connection pooling for Redis, set appropriate timeouts, and consider using Redis Sentinel for high availability. Always test your rate limiter under load to ensure it doesn’t become a bottleneck itself.

Building this distributed rate limiter transformed how I handle API scaling. It’s now a fundamental part of my toolkit for any serious web application. The combination of Redis’s speed, Node.js’s event-driven architecture, and TypeScript’s type safety creates a robust solution that grows with your needs.

I’d love to hear about your experiences with rate limiting! Have you encountered unique challenges or found creative solutions? Share your thoughts in the comments below, and if this guide helped you, please like and share it with others who might benefit. Let’s keep the conversation going—what’s the most interesting rate limiting problem you’ve solved?

Keywords: distributed rate limiter, Redis rate limiting, Node.js rate limiter, TypeScript rate limiting, API rate limiting, sliding window algorithm, token bucket algorithm, Express middleware, Redis clustering, distributed systems



Similar Posts
Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Full-Stack Development

Build full-stack TypeScript apps with Next.js and Prisma ORM. Learn seamless integration, type-safe database operations, and API routes for scalable web development.

Blog Image
Build Production-Ready Event-Driven Microservices with NestJS, RabbitMQ, and MongoDB

Learn to build production-ready event-driven microservices with NestJS, RabbitMQ & MongoDB. Master message queuing, event sourcing & distributed systems deployment.

Blog Image
Building Type-Safe Event-Driven Microservices: NestJS, RabbitMQ & Prisma Complete Guide

Learn to build scalable event-driven microservices with NestJS, RabbitMQ, and Prisma. Master type-safe messaging, error handling, and testing strategies for robust distributed systems.

Blog Image
Complete Guide to Next.js Prisma Integration: Build Type-Safe Full-Stack Apps in 2024

Build type-safe full-stack apps with Next.js and Prisma ORM. Learn seamless integration, TypeScript support, and powerful database operations. Start building today!

Blog Image
Build High-Performance GraphQL API: NestJS, Prisma, Redis Caching Tutorial for Production

Learn to build a scalable GraphQL API with NestJS, Prisma ORM, and Redis caching. Master authentication, real-time subscriptions, and performance optimization for production-ready applications.

Blog Image
Production-Ready Event-Driven Microservices with NestJS, RabbitMQ, and TypeScript

Learn to build production-ready event-driven microservices with NestJS, RabbitMQ & TypeScript. Includes error handling, tracing, and Docker deployment.