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

typescript
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

typescript
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

typescript
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:

typescript
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:

typescript
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:

typescript
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

typescript
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

typescript
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):

typescript
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:

typescript
// 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:

typescript
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:

typescript
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?

Want more insights like this?

Join thousands of CTOs and technical leaders getting weekly insights on leadership and system design.

No spam. Unsubscribe anytime.