•7 min read

System Design: Building a Distributed Rate Limiter

System Design: Building a Distributed Rate Limiter

Introduction

Audio Briefing
0:00 / 0:00

Any public-facing API requires rate limiting. Without it, your system is vulnerable to brute-force attacks, DDoS attempts, and noisy neighbor problems where a single aggressive client degrades the experience for everyone else.

Designing a rate limiter for a single server is relatively simple. Designing one for a globally distributed microservices architecture handling millions of requests per second is a classic system design challenge. In this article, we'll break down the architecture and algorithms required.

Advertisement

1. Choosing the Right Algorithm

The heart of a rate limiter is the algorithm it uses to count requests.

Token Bucket

  • How it works: Imagine a bucket holding tokens. Tokens are added at a fixed rate. Every request costs one token. If the bucket is empty, the request is dropped.
  • Pros: Memory efficient, allows for short bursts of traffic.
  • Cons: Tricky to tune burst size vs. sustained rate.

Fixed Window Counter

  • How it works: Divides time into fixed windows (e.g., 12:00 to 12:01). Increments a counter for every request. Resets at the start of the next window.
  • Pros: Very easy to implement.
  • Cons: Spike traffic at the edges of the window. A client could send their entire quota at 12:00:59 and again at 12:01:01, effectively doubling their allowed rate in a short span.

Sliding Window Log

  • How it works: Keeps a timestamp log of every request for a user. Drops requests if the log size exceeds the limit within the time frame.
  • Pros: Highly accurate.
  • Cons: Consumes significant memory and compute to maintain and prune logs. Not viable for massive scale.

Sliding Window Counter (The Winner)

  • How it works: A hybrid approach. It tracks the counter for the previous fixed window and the current fixed window, and calculates a weighted average based on the current time overlapping the window.
  • Pros: Smooths out edge spikes, highly memory efficient, highly accurate.

2. Architecture for Distributed Systems

In a multi-server setup, maintaining state becomes the primary challenge. If a load balancer routes User A's first request to Server 1 and their second to Server 2, how do they share the rate limit counter?

Option A: Sticky Sessions

Route all traffic for a specific user to the same server. The server keeps the rate limit state in local memory.

  • Verdict: Bad idea. It unevenly distributes load and fails gracefully when a server crashes.

Option B: Centralized Datastore (Redis)

All servers read and write to a centralized Redis cluster. Redis is fast enough (in-memory) to handle the latency requirements.

  • Verdict: The standard approach. However, it introduces network latency and race conditions.

3. Handling Race Conditions in Redis

If two requests for the same user hit two different API servers simultaneously, both might read the counter as 4, both increment to 5, and both allow the request, bypassing the limit.

Solution: Lua Scripts

Redis allows you to execute Lua scripts atomically. You can write a script that fetches the counter, checks the limit, increments it, and updates the TTL in a single atomic operation.

-- Simple Token Bucket Lua Script
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local current = tonumber(redis.call('get', key) or "0")

if current + 1 > limit then
    return 0 -- Rejected
else
    redis.call('INCRBY', key, 1)
    redis.call('EXPIRE', key, 60)
    return 1 -- Allowed
end
Advertisement

4. Performance Optimization: Local Cache + Async Sync

For ultra-high throughput where even Redis latency is unacceptable, you can use a hybrid approach.

  • API servers keep a local, in-memory rate limiter.
  • They sync their local counts to the central Redis cluster asynchronously every few seconds.
  • Tradeoff: This trades strict accuracy for performance. A client might exceed their limit slightly between sync windows, but the system remains highly available and fast.

Conclusion

Building a distributed rate limiter requires balancing strict enforcement against system latency. For most modern APIs, combining a Sliding Window algorithm implemented via atomic Redis Lua scripts provides the best balance of accuracy, performance, and scalability.

Deep Dive: The Core Mechanics

When we look beneath the surface, the underlying mechanics reveal a complex interplay of systems. In modern development, understanding these mechanics is what separates a novice from an expert.

Consider this practical example:

// A comprehensive example demonstrating advanced patterns
class ServiceManager {
  constructor() {
    this.services = new Map();
    this.initialized = false;
  }

  register(name, service) {
    if (this.services.has(name)) {
      throw new Error(`Service ${name} already registered`);
    }
    this.services.set(name, service);
  }

  async initializeAll() {
    this.initialized = true;
    for (const [name, service] of this.services) {
      if (typeof service.init === 'function') {
        await service.init();
      }
    }
  }

  get(name) {
    if (!this.initialized) {
      console.warn('Accessing services before initialization');
    }
    return this.services.get(name);
  }
}

This pattern ensures that our architecture remains scalable and robust even as business requirements change. It's a fundamental approach that pays dividends in large-scale applications.

Real-world Application and Scaling

Implementing this in a production environment introduces a new set of challenges. We must account for concurrency, state management, and memory leaks.

For instance, when dealing with high-throughput systems, every micro-optimization counts. We often rely on profiling tools to identify bottlenecks that aren't apparent during local development.

The diagram above illustrates a typical deployment strategy where our application scales horizontally.

Test Your Understanding

You Might Also Like

Frequently Asked Questions

Fixed Window counters allow burst traffic spikes at boundary edges: a user can exhaust their full quota in the last second of window A and immediately send another full quota in the first second of window B. Sliding Window Counter calculates a weighted average between the previous and current window, smoothing boundary bursts with minimal memory overhead (two integer keys per client).
A standard GET followed by an INCR command creates a race condition where concurrent worker servers both read the same counter value before incrementing, allowing clients to exceed limits. Executing a Lua script runs atomically on the single-threaded Redis engine, guaranteeing that threshold verification, incrementing, and TTL expiration occur as an indivisible operation.
High-availability architectures implement circuit breakers with configurable fail-open or fail-closed modes. Standard read/query APIs fail open when Redis times out (>5ms) to preserve user uptime, whereas security-critical endpoints (such as login password verification, MFA verification, and credit card processing) fail closed to block brute-force attacks.
Synchronizing every API hit across multi-region Redis instances introduces intolerable 100ms+ cross-region network latency. Leading architectures partition the global quota across regions (for example, 40% to US-East, 30% to EU-Central, 30% to AP-Southeast) and synchronize counter deltas asynchronously in background batches using message brokers.
Return HTTP 429 Too Many Requests along with standard rate limit headers: RateLimit-Limit (total quota allowed in window), RateLimit-Remaining (available tokens in current window), RateLimit-Reset (seconds until quota resets), and Retry-After (minimum delay in seconds before clients should retry).
Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement