Marcio Cunha

Distributed Rate Limiting with Redis and Lua Scripts: Atomic Control

Learn how to implement distributed rate limiting in microservices using Redis and Lua scripts. Ensure atomic operations, eliminate destructive concurrency, and protect APIs against traffic spikes with practical code examples.

Marcio Cunha12 min
Also available in:EspañolPortuguês
Summary
  • Atomic operations in Redis prevent simultaneous requests from corrupting access counters in distributed environments.
  • Lua scripts execute directly within the database server, ensuring that reading, logic, and writing occur without interruption.
  • The sliding window approach offers superior accuracy compared to the classic fixed window model.
  • In-memory cache servers eliminate network bottlenecks by consolidating decision-making into a single call.
  • High-scale systems require combined blocking and graceful degradation strategies to maintain stability under traffic surges.

The Challenge of Rate Limiting in Distributed Architectures

When developing modern cloud-native applications, splitting systems into independent microservices is a common practice. While this improves flexibility, it introduces a classic architectural problem: how do we prevent a single user or a malicious script from overwhelming our entry points? Rate limiting acts as a virtual bouncer at the door, restricting how many times a client can call a specific endpoint within a defined time window.

In traditional monolithic applications, storing this counter in the server's local RAM is simple and effective. However, when scaling infrastructure to run across dozens of parallel servers managed by a traffic router, the scenario changes completely. If Server A counts three requests from a user and Server B counts four for the same client, the global limit can easily be violated because the servers do not talk to each other in real-time about every individual click.

To centralize this counting process, developers rely on shared in-memory data stores, with Redis being the industry standard due to its extreme speed. Yet, simply dropping Redis into the architecture does not magically solve everything. If two servers try to update the same user's limit in the exact same millisecond, we risk facing a race condition where overlapping updates overwrite each other, producing inaccurate data and allowing unauthorized access.

Understanding the Problem of Destructive Concurrency

Imagine two friends trying to add money to a piggy bank at the exact same time without checking what the other is depositing. The first friend reads the current balance of ten dollars, adds five, and prepares to close the lid. At that exact microsecond, the second friend reads the same initial balance of ten dollars, adds ten, and closes the lid. The final balance in the piggy bank ends up being twenty dollars instead of the correct twenty-five. The first friend's deposit was wiped out by the second.

In the database world, this phenomenon is known as read-modify-write without atomic protection. When a microservice needs to check if a user has exceeded their request limit, it typically executes three distinct steps: reads the current counter value from Redis, increments it in application code, and writes the new value back to the cache server. If hundreds of requests arrive simultaneously for the same user, these steps overlap, and hundreds of hits end up counting as just one.

The direct consequence of this flaw is the complete breakdown of API security and resource protection policies. A malicious user sending requests in perfectly synchronized bursts can bypass established limits, overloading the primary database or exhausting expensive processing resources. Therefore, we need a mechanism that guarantees these three steps execute as an indivisible transaction where nothing else can interfere midway through.

The Solution with Redis Lua Scripts

To eliminate destructive concurrency without locking entire tables or building complex distributed locking mechanisms, we can rely on Lua scripts built directly into Redis. Lua is a lightweight, fast language widely used to extend functionality in high-performance software. In practice, Redis allows you to send a block of Lua code to be executed directly inside the database engine.

The major advantage of this approach is native atomicity. Redis guarantees that while a Lua script is running, no other operation or command sent by other clients will be executed. It is as if the database pauses the clock for a few milliseconds to run your entire logic — reading, validating, and writing — in a single unbroken breath. No other server can meddle in the middle of the process.

Beyond ensuring data integrity, using Lua scripts drastically reduces network traffic between the application and the cache server. Instead of making multiple round-trips to ask for values and update them, the application sends a single text payload containing the script and necessary parameters, receives the final decision immediately, and determines whether to accept or reject the request.

Practical Implementation of the Sliding Window Algorithm

Several rate limiting strategies exist, such as the fixed window (which resets the count at the turn of every hour or minute) and the leaky bucket. One of the most robust approaches for high-precision environments is the sliding window based on timestamps stored in Redis sorted sets, known as ZSETs.

Below is an example of a Lua script that implements this logic in a concise and fully atomic way. It removes old records that have fallen outside the time window, counts how many requests remain in the current period, and adds the new request if the limit has not yet been reached.

local key = KEYS[1]local now = tonumber(ARGV[1])local window = tonumber(ARGV[2])local limit = tonumber(ARGV[3])local clear_before = now - window-- Remove old records outside the time windowredisch.call('ZREMRANGEBYSCORE', key, 0, clear_before)-- Count remaining requests in the windowlocal current_requests = redis.call('ZCARD', key)if current_requests < limit then    -- Add current request with timestamp as score    redis.call('ZADD', key, now, now)    -- Set an expiration time to clean up the key automatically    redis.call('EXPIRE', key, math.ceil(window / 1000))    return 1else    return 0end

In the code above, KEYS[1] represents the unique user or IP key being monitored. The argument now provides the exact timestamp of the request in milliseconds, while window defines the analysis window size (for example, sixty seconds), and limit establishes the maximum number of allowed calls. If the script returns one, the request is approved; if it returns zero, the system rejects it for exceeding the rate limit.

Integrating the Lua script into your backend application involves loading and caching the script using its cryptographic SHA1 hash. This avoids sending the full script text over the network on every client request, optimizing bandwidth and latency even under heavy workloads.

Operational Considerations and Monitoring

Although Redis Lua scripts solve concurrency elegantly, developers must be mindful of the script execution time. Since Redis operates on a single-threaded event loop to process commands, any Lua script that takes too long to run will freeze the entire server, blocking all other dependent applications.

Therefore, keep your scripts concise and focused strictly on necessary mathematical logic. Avoid complex loops, excessive string manipulation, or expensive operations within the script. Constantly monitor key metrics like Redis response time and CPU utilization to spot bottlenecks before they impact end users.

Another vital aspect is capacity planning for the Redis cluster. Since each rate limiting key consumes memory to store timestamp records, ensure your keys have proper expiration policies configured via the EXPIRE command. This ensures inactive users automatically release RAM space.

Conclusion and Next Steps

Distributed rate limiting ceases to be a complex puzzle when combining the raw speed of Redis with the atomicity provided by Lua scripts. This architecture eliminates race condition risks and protects APIs from abusive traffic without sacrificing microservice performance and horizontal scalability.

When implementing this solution in your projects, start by testing behavior under simulated load using stress testing tools. Validate that configured limits respond correctly under bursts and fine-tune expiration times to match your client usage profiles, building a resilient application ready to scale.