Building a Rate Limiter in Ruby
Rate limiting is one of those problems that sounds simple until you actually sit down to implement it. You need to track requests, enforce limits, and handle edge cases around time windows — all without becoming a bottleneck yourself.
Let's build one from scratch using the sliding window algorithm, backed by Redis.
The Algorithm
Most rate limiters use one of three approaches:
- Fixed window — count requests in fixed time buckets (simple but bursty at boundaries)
- Sliding window log — store each request timestamp (accurate but memory-heavy)
- Sliding window counter — a hybrid that approximates the sliding window using two fixed windows
We'll go with the sliding window counter. It gives us good accuracy without storing every individual request.
The best rate limiter is one your users never notice. It should be invisible during normal usage and firm during abuse.
The idea is straightforward: we maintain counters for the current and previous time windows, then weight the previous window's count based on how far we are into the current one.
Implementation
Here's the core class. It takes a limit and a window duration in seconds:
class RateLimiter
def initialize(redis:, limit:, window: 60)
@redis = redis
@limit = limit
@window = window
end
def allow?(key)
now = Time.now.to_f
current_window = (now / @window).floor
previous_window = current_window - 1
elapsed = (now % @window) / @window
current_key = "rate:#{key}:#{current_window}"
previous_key = "rate:#{key}:#{previous_window}"
current_count, previous_count = @redis.pipelined do |pipe|
pipe.get(current_key)
pipe.get(previous_key)
end
weighted_count = (previous_count.to_i * (1 - elapsed)) + current_count.to_i
if weighted_count < @limit
@redis.pipelined do |pipe|
pipe.incr(current_key)
pipe.expire(current_key, @window * 2)
end
true
else
false
end
end
end
A few things worth noting about this code:
- We use
pipelinedto batch Redis commands — two round trips become one - The
expireis set towindow * 2so old keys clean themselves up - The
elapsedratio is the key insight: it weights the previous window's count proportionally
Integrating with Rack
Wrapping this in a Rack middleware makes it available to any Ruby web app:
class RateLimitMiddleware
def initialize(app, redis:, limit: 100, window: 60)
@app = app
@limiter = RateLimiter.new(redis: redis, limit: limit, window: window)
end
def call(env)
key = env["REMOTE_ADDR"]
if @limiter.allow?(key)
@app.call(env)
else
[429, { "Retry-After" => "60" }, ["Rate limit exceeded"]]
end
end
end
Then in your config.ru:
use RateLimitMiddleware, redis: Redis.new, limit: 100
run MyApp
Testing It
Here's a minimal test to verify the windowing behaviour:
class RateLimiterTest < ActiveSupport::TestCase
setup do
@redis = Redis.new
@redis.flushdb
@limiter = RateLimiter.new(redis: @redis, limit: 5, window: 10)
end
test "allows requests under the limit" do
5.times { assert @limiter.allow?("user:1") }
end
test "blocks requests over the limit" do
5.times { @limiter.allow?("user:1") }
assert_not @limiter.allow?("user:1")
end
test "tracks keys independently" do
5.times { @limiter.allow?("user:1") }
assert @limiter.allow?("user:2")
end
end
Performance Considerations
The Redis commands we're using — GET, INCR, EXPIRE — are all O(1). With pipelining, each rate limit check is a single round trip containing two commands. On a modern setup, you're looking at sub-millisecond overhead.
Here's a quick benchmark comparing approaches:
| Algorithm | Memory per key | Accuracy | Ops/check |
|---|---|---|---|
| Fixed window | 1 counter | Low | 2 |
| Sliding log | N timestamps | Exact | N + 1 |
| Sliding counter | 2 counters | High | 4 |
The sliding counter hits the sweet spot: near-exact accuracy with constant memory.
Going Further
A few enhancements worth considering:
- Different limits per endpoint — pass the route as part of the key
- Graduated responses — return
Retry-Afterheaders with the actual wait time - Distributed counting — if you're behind multiple Redis instances, look into
CRDTcounters
For most applications, the version above is more than sufficient. It's ~30 lines of actual logic, constant memory, and handles the edge cases that trip up naive implementations.
The code is available as a gem: just gem install sliding_window_limiter. Or, better yet, copy the class into your project — it's small enough that a dependency isn't worth it.