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 Next.js Prisma Integration: Build Type-Safe Full-Stack Apps in 2024

Build powerful full-stack apps with Next.js and Prisma ORM integration. Learn type-safe database queries, API routes, and seamless development workflows for modern web applications.

Blog Image
Complete Next.js Prisma Integration Guide: Build Type-Safe Full-Stack Apps with Database ORM

Learn how to integrate Next.js with Prisma ORM for type-safe full-stack development. Build modern web apps with seamless database integration and TypeScript support.

Blog Image
Build Multi-Tenant SaaS Applications with NestJS, Prisma and PostgreSQL Row-Level Security Guide

Learn to build a scalable multi-tenant SaaS app with NestJS, Prisma & PostgreSQL RLS. Master tenant isolation, JWT auth, and performance optimization for production-ready applications.

Blog Image
Build Production-Ready GraphQL APIs with NestJS, Prisma, and Redis: Complete Developer Guide

Learn to build scalable GraphQL APIs with NestJS, Prisma, and Redis caching. Master authentication, DataLoader optimization, and production deployment strategies.

Blog Image
Build Real-Time Analytics Dashboard with Node.js Streams ClickHouse and Server-Sent Events Performance Guide

Learn to build a high-performance real-time analytics dashboard using Node.js Streams, ClickHouse, and SSE. Complete tutorial with code examples and optimization tips.

Blog Image
Build Production-Ready GraphQL APIs with NestJS, Prisma, and Redis: Complete Developer Guide

Learn to build scalable GraphQL APIs with NestJS, Prisma, and Redis caching. Master authentication, real-time subscriptions, and production deployment.