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:

  1. Fixed window — count requests in fixed time buckets (simple but bursty at boundaries)
  2. Sliding window log — store each request timestamp (accurate but memory-heavy)
  3. 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:

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:

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.