Skip to main content

Implementing Rate Limiting with Redis

October 12, 2025By CTO18 min read
...
code

Production-ready rate limiting implementation using Redis with multiple algorithms: fixed window, sliding window, and token bucket.

Language: TypeScript
Difficulty: intermediate

Implementing Rate Limiting with Redis

Rate limiting is essential for protecting your API from abuse, ensuring fair usage, and maintaining system stability. Here's how to implement production-ready rate limiting using Redis.

Why Redis for Rate Limiting?

Benefits:

  • Atomic operations (critical for accuracy)
  • Fast in-memory storage
  • Built-in TTL (time-to-live)
  • Centralized state (works across multiple servers)
  • Excellent performance (100k+ ops/sec)

Alternatives:

  • In-memory (doesn't work with multiple servers)
  • Database (too slow)
  • Third-party services (dependency and cost)

Three Rate Limiting Algorithms

1. Fixed Window Counter

How it works:

  • Count requests in fixed time windows
  • Reset counter at window boundaries

Pros:

  • Simple to implement
  • Memory efficient
  • Predictable behavior

Cons:

  • Burst at window boundaries
  • Not perfectly fair

Best for:

  • Simple rate limits
  • Low precision requirements
  • Resource-constrained environments

2. Sliding Window Log

How it works:

  • Track timestamp of each request
  • Count requests in rolling window

Pros:

  • Perfectly accurate
  • No boundary burst issues
  • Fair across time

Cons:

  • Higher memory usage
  • More complex

Best for:

  • Critical APIs
  • High precision requirements
  • Fairness is important

3. Token Bucket

How it works:

  • Bucket fills with tokens over time
  • Each request consumes a token
  • Burst allowed if tokens available

Pros:

  • Handles bursts gracefully
  • Smooth rate limiting
  • Memory efficient

Cons:

  • More complex to implement
  • Harder to reason about

Best for:

  • APIs with bursty traffic
  • Flexible rate limiting
  • Better UX

Implementation

Fixed Window Counter

import { Redis } from 'ioredis';

interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: Date;
}

export class FixedWindowRateLimiter {
  constructor(
    private redis: Redis,
    private maxRequests: number,
    private windowMs: number
  ) {}

  async checkLimit(key: string): Promise<RateLimitResult> {
    const now = Date.now();
    const windowKey = this.getWindowKey(key, now);

    // Increment counter atomically
    const count = await this.redis.incr(windowKey);

    // Set TTL on first request in window
    if (count === 1) {
      await this.redis.pexpire(windowKey, this.windowMs);
    }

    const allowed = count <= this.maxRequests;
    const remaining = Math.max(0, this.maxRequests - count);
    const resetAt = new Date(this.getWindowStart(now) + this.windowMs);

    return { allowed, remaining, resetAt };
  }

  private getWindowKey(key: string, timestamp: number): string {
    const windowStart = this.getWindowStart(timestamp);
    return `ratelimit:${key}:${windowStart}`;
  }

  private getWindowStart(timestamp: number): number {
    return Math.floor(timestamp / this.windowMs) * this.windowMs;
  }
}

// Usage
const limiter = new FixedWindowRateLimiter(
  redis,
  100,    // max requests
  60000   // per 60 seconds
);

const result = await limiter.checkLimit(`user:${userId}`);
if (!result.allowed) {
  throw new Error('Rate limit exceeded');
}

Sliding Window Log

export class SlidingWindowRateLimiter {
  constructor(
    private redis: Redis,
    private maxRequests: number,
    private windowMs: number
  ) {}

  async checkLimit(key: string): Promise<RateLimitResult> {
    const now = Date.now();
    const windowStart = now - this.windowMs;
    const listKey = `ratelimit:${key}`;

    // Use Lua script for atomic operations
    const script = `
      local listKey = KEYS[1]
      local now = tonumber(ARGV[1])
      local windowStart = tonumber(ARGV[2])
      local maxRequests = tonumber(ARGV[3])
      local windowMs = tonumber(ARGV[4])

      -- Remove old entries
      redis.call('ZREMRANGEBYSCORE', listKey, 0, windowStart)

      -- Count current requests
      local count = redis.call('ZCARD', listKey)

      if count < maxRequests then
        -- Add new request
        redis.call('ZADD', listKey, now, now)
        redis.call('PEXPIRE', listKey, windowMs)
        return {1, maxRequests - count - 1}
      else
        return {0, 0}
      end
    `;

    const result = await this.redis.eval(
      script,
      1,
      listKey,
      now,
      windowStart,
      this.maxRequests,
      this.windowMs
    ) as [number, number];

    const [allowed, remaining] = result;
    const resetAt = new Date(now + this.windowMs);

    return {
      allowed: allowed === 1,
      remaining,
      resetAt
    };
  }
}

Token Bucket

export class TokenBucketRateLimiter {
  constructor(
    private redis: Redis,
    private capacity: number,      // max tokens
    private refillRate: number,    // tokens per second
  ) {}

  async checkLimit(key: string, cost: number = 1): Promise<RateLimitResult> {
    const bucketKey = `ratelimit:tb:${key}`;
    const now = Date.now();

    const script = `
      local bucketKey = KEYS[1]
      local capacity = tonumber(ARGV[1])
      local refillRate = tonumber(ARGV[2])
      local cost = tonumber(ARGV[3])
      local now = tonumber(ARGV[4])

      -- Get current bucket state
      local bucket = redis.call('HMGET', bucketKey, 'tokens', 'last_refill')
      local tokens = tonumber(bucket[1]) or capacity
      local lastRefill = tonumber(bucket[2]) or now

      -- Calculate tokens to add based on time passed
      local timePassed = (now - lastRefill) / 1000
      local tokensToAdd = timePassed * refillRate
      tokens = math.min(capacity, tokens + tokensToAdd)

      local allowed = 0
      local remaining = tokens

      if tokens >= cost then
        tokens = tokens - cost
        allowed = 1
        remaining = tokens
      end

      -- Update bucket state
      redis.call('HMSET', bucketKey, 'tokens', tokens, 'last_refill', now)
      redis.call('EXPIRE', bucketKey, 3600)  -- Expire after 1 hour of inactivity

      return {allowed, math.floor(remaining)}
    `;

    const result = await this.redis.eval(
      script,
      1,
      bucketKey,
      this.capacity,
      this.refillRate,
      cost,
      now
    ) as [number, number];

    const [allowed, remaining] = result;

    return {
      allowed: allowed === 1,
      remaining,
      resetAt: new Date(now + ((this.capacity - remaining) / this.refillRate) * 1000)
    };
  }
}

// Usage
const limiter = new TokenBucketRateLimiter(
  redis,
  100,   // 100 tokens capacity
  10     // refill 10 tokens per second
);

// Normal request (costs 1 token)
await limiter.checkLimit(`user:${userId}`);

// Expensive operation (costs 10 tokens)
await limiter.checkLimit(`user:${userId}`, 10);

Express Middleware

Integrate rate limiting into your Express app:

import { Request, Response, NextFunction } from 'express';

export function createRateLimitMiddleware(
  limiter: FixedWindowRateLimiter | SlidingWindowRateLimiter | TokenBucketRateLimiter,
  keyGenerator: (req: Request) => string = (req) => req.ip
) {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      const key = keyGenerator(req);
      const result = await limiter.checkLimit(key);

      // Set rate limit headers
      res.setHeader('X-RateLimit-Limit', limiter.maxRequests || limiter.capacity);
      res.setHeader('X-RateLimit-Remaining', result.remaining);
      res.setHeader('X-RateLimit-Reset', result.resetAt.getTime());

      if (!result.allowed) {
        res.setHeader('Retry-After', Math.ceil((result.resetAt.getTime() - Date.now()) / 1000));

        return res.status(429).json({
          error: 'Too Many Requests',
          message: 'Rate limit exceeded',
          retryAfter: result.resetAt.toISOString()
        });
      }

      next();
    } catch (error) {
      // Fail open: allow request if rate limiter fails
      console.error('Rate limiter error:', error);
      next();
    }
  };
}

// Usage
import express from 'express';

const app = express();

// Global rate limit: 100 requests per minute per IP
app.use(createRateLimitMiddleware(
  new FixedWindowRateLimiter(redis, 100, 60000)
));

// API-specific rate limit: 1000 requests per hour per API key
app.use('/api', createRateLimitMiddleware(
  new SlidingWindowRateLimiter(redis, 1000, 3600000),
  (req) => req.headers['x-api-key'] as string
));

// Expensive operation: token bucket with bursts
app.post('/api/search', createRateLimitMiddleware(
  new TokenBucketRateLimiter(redis, 50, 5),  // 50 capacity, 5/sec refill
  (req) => `user:${req.user?.id}`
));

Advanced Patterns

Tiered Rate Limiting

Different limits for different user tiers:

class TieredRateLimiter {
  private limiters: Map<string, SlidingWindowRateLimiter> = new Map();

  constructor(
    private redis: Redis,
    private tiers: Record<string, { maxRequests: number; windowMs: number }>
  ) {
    for (const [tier, config] of Object.entries(tiers)) {
      this.limiters.set(
        tier,
        new SlidingWindowRateLimiter(redis, config.maxRequests, config.windowMs)
      );
    }
  }

  async checkLimit(tier: string, key: string): Promise<RateLimitResult> {
    const limiter = this.limiters.get(tier);
    if (!limiter) {
      throw new Error(`Unknown tier: ${tier}`);
    }
    return limiter.checkLimit(key);
  }
}

// Usage
const tieredLimiter = new TieredRateLimiter(redis, {
  free: { maxRequests: 10, windowMs: 60000 },      // 10/min
  basic: { maxRequests: 100, windowMs: 60000 },    // 100/min
  premium: { maxRequests: 1000, windowMs: 60000 }  // 1000/min
});

app.use(async (req, res, next) => {
  const tier = req.user?.subscription || 'free';
  const result = await tieredLimiter.checkLimit(tier, `user:${req.user?.id}`);

  if (!result.allowed) {
    return res.status(429).json({
      error: 'Rate limit exceeded',
      tier,
      upgradeUrl: '/pricing'
    });
  }
  next();
});

Distributed Rate Limiting

For multiple Redis instances:

class DistributedRateLimiter {
  constructor(
    private redisClients: Redis[],
    private maxRequests: number,
    private windowMs: number
  ) {}

  async checkLimit(key: string): Promise<RateLimitResult> {
    // Check all Redis instances
    const results = await Promise.all(
      this.redisClients.map(client => {
        const limiter = new FixedWindowRateLimiter(client, this.maxRequests, this.windowMs);
        return limiter.checkLimit(key);
      })
    );

    // Aggregate results (take minimum remaining)
    const minRemaining = Math.min(...results.map(r => r.remaining));
    const allowed = results.every(r => r.allowed);

    return {
      allowed,
      remaining: minRemaining,
      resetAt: results[0].resetAt
    };
  }
}

Testing

import { describe, it, expect, beforeEach } from '@jest/globals';
import Redis from 'ioredis-mock';

describe('FixedWindowRateLimiter', () => {
  let redis: Redis;
  let limiter: FixedWindowRateLimiter;

  beforeEach(() => {
    redis = new Redis();
    limiter = new FixedWindowRateLimiter(redis, 5, 1000); // 5 requests per second
  });

  it('should allow requests within limit', async () => {
    for (let i = 0; i < 5; i++) {
      const result = await limiter.checkLimit('test-key');
      expect(result.allowed).toBe(true);
      expect(result.remaining).toBe(4 - i);
    }
  });

  it('should block requests over limit', async () => {
    // Use up the limit
    for (let i = 0; i < 5; i++) {
      await limiter.checkLimit('test-key');
    }

    // Next request should be blocked
    const result = await limiter.checkLimit('test-key');
    expect(result.allowed).toBe(false);
    expect(result.remaining).toBe(0);
  });

  it('should reset after window', async () => {
    // Use up the limit
    for (let i = 0; i < 5; i++) {
      await limiter.checkLimit('test-key');
    }

    // Wait for window to reset
    await new Promise(resolve => setTimeout(resolve, 1100));

    // Should allow requests again
    const result = await limiter.checkLimit('test-key');
    expect(result.allowed).toBe(true);
  });
});

Monitoring and Observability

class ObservableRateLimiter {
  constructor(
    private limiter: FixedWindowRateLimiter,
    private metrics: MetricsClient
  ) {}

  async checkLimit(key: string): Promise<RateLimitResult> {
    const start = Date.now();
    const result = await this.limiter.checkLimit(key);
    const duration = Date.now() - start;

    // Record metrics
    this.metrics.histogram('rate_limiter.check_duration', duration);
    this.metrics.increment('rate_limiter.checks_total');

    if (!result.allowed) {
      this.metrics.increment('rate_limiter.blocked_total', { key });
    }

    return result;
  }
}

Best Practices

1. Fail Open

If Redis is down, allow requests (don't block all traffic):

try {
  const result = await limiter.checkLimit(key);
  if (!result.allowed) {
    return res.status(429).json({ error: 'Rate limit exceeded' });
  }
} catch (error) {
  // Log error but allow request
  logger.error('Rate limiter error', error);
}

2. Use Lua Scripts

For atomic operations across multiple Redis commands:

// Good: Atomic
const script = `
  local current = redis.call('INCR', KEYS[1])
  redis.call('EXPIRE', KEYS[1], ARGV[1])
  return current
`;

// Bad: Race condition
const current = await redis.incr(key);
await redis.expire(key, ttl);

3. Set Appropriate Headers

Help clients handle rate limits:

res.setHeader('X-RateLimit-Limit', limit);
res.setHeader('X-RateLimit-Remaining', remaining);
res.setHeader('X-RateLimit-Reset', resetAt.getTime());
res.setHeader('Retry-After', retryAfterSeconds);

4. Different Limits for Different Endpoints

More generous limits for cheap operations:

app.get('/health', createRateLimitMiddleware(
  new FixedWindowRateLimiter(redis, 1000, 60000)  // 1000/min
));

app.post('/api/search', createRateLimitMiddleware(
  new FixedWindowRateLimiter(redis, 10, 60000)    // 10/min
));

Production Checklist

  • [ ] Choose appropriate algorithm for your use case
  • [ ] Set realistic limits (test with real traffic)
  • [ ] Implement proper error handling (fail open)
  • [ ] Add monitoring and alerting
  • [ ] Set standard rate limit headers
  • [ ] Document limits for API consumers
  • [ ] Test with realistic load
  • [ ] Have Redis failover plan
  • [ ] Consider burst allowances
  • [ ] Implement tiered limits if needed

Further Reading


Have you implemented rate limiting? What challenges did you face? What algorithm works best for your use case?