Menu
Dev.to #systemdesign·July 12, 2026

Designing a Distributed Rate Limiter with Redis and Lua

This article discusses the architectural evolution of a rate limiter, moving from a flawed in-memory approach to a robust distributed solution using Redis and Lua scripting. It highlights the critical need for atomic operations and global consistency in a distributed environment to prevent race conditions and ensure fair rate limiting across multiple API gateway instances. The solution leverages Redis's single-threaded nature and Lua scripts to achieve atomic increment-and-check operations.

Read original on Dev.to #systemdesign

The Challenge: Flawed In-Memory Rate Limiting

Initially, the rate limiter was implemented with per-instance in-memory counters. While simple for single-node applications, this approach quickly failed in a distributed production environment. Key issues included lack of global state, leading to inconsistent rate limits across different service instances, and race conditions where multiple threads could bypass the limit due to non-atomic check-then-increment operations. This highlighted the necessity for a centralized, globally consistent mechanism.

Leveraging Redis for Global Consistency and Atomicity

The core insight was to centralize the rate limiting logic in a system that guarantees atomicity and provides a single source of truth. Redis, with its single-threaded event loop, is an excellent candidate for this. By executing the increment, expiry-set, and comparison operations within a Lua script on Redis, the entire sequence becomes an atomic transaction. This prevents race conditions and ensures that all API gateway instances operate against the same, consistent view of the current rate limit for a given user or key.

lua
-- rate_limiter.lua
-- KEYS[1] = redis key for this user (e.g., "rate:user:123")
-- ARGV[1] = limit (e.g., 100)
-- ARGV[2] = window size in seconds (e.g., 60)
local current = redis.call('GET', KEYS[1])
if current == false then
  -- first request in this window
  redis.call('SET', KEYS[1], 1)
  redis.call('EXPIRE', KEYS[1], ARGV[2])
  return 1 -- allowed
end
if tonumber(current) >= tonumber(ARGV[1]) then
  return 0 -- deny
end
local newval = redis.call('INCR', KEYS[1])
return newval -- allowed (new count)

Trade-offs of the Redis-based Approach

  • Latency vs. Consistency: Introducing a Redis round-trip for every request adds a small amount of network latency. However, this is a necessary trade-off for achieving global consistency and preventing unfair blocking or excessive traffic.
  • Complexity: Using Lua scripts might seem like an added layer of complexity, but it's a one-time development cost that significantly improves correctness and reliability.
  • Throughput: Redis is highly performant (hundreds of thousands of operations/sec), sufficient for most API rate limiting needs. For extreme cases requiring millions of operations, further scaling like sharding Redis or specialized rate-limiting services might be considered.
  • Scalability: The system scales horizontally by adding more API gateway instances; Redis acts as the centralized, consistent counter.
💡

Key System Design Takeaway

When designing distributed systems, identifying and isolating stateful operations that require global consistency into a single, atomic service (like Redis with Lua for counters) is crucial. This pattern helps avoid complex distributed consensus protocols for simpler tasks and ensures predictable behavior across all nodes.

rate limitingRedisLua scriptingdistributed systemsAPI gatewayscalabilityatomicityconsistency

Comments

Loading comments...