DEV Community

Timevolt
Timevolt

Posted on

Rate Limiting Like a Boss: Monolith vs Microservices (Inspired by The Matrix)

The Quest Begins (The "Why")

Imagine you’re building a shiny new API that lets users upload cat pictures. Everything’s going great until one day a meme‑lord decides to hammer your endpoint with a thousand requests per second. Your servers start sweating, latency spikes, and the beloved cat gallery turns into a slideshow of error pages. You need a rate limiter—fast.

I spent a weekend wrestling with this problem, first hacking together a quick in‑memory counter inside our monolith, then tearing it apart when we started splitting the app into microservices. The moment I realized that the same logical piece of code behaved totally differently depending on where it lived felt like Neo finally seeing the code of the Matrix. That’s the insight I want to share with you today.

The Revelation (The Insight)

Here’s the core truth: a rate limiter is only as good as the shared state it can atomically update. In a monolith, your process already has a single address space—you can reach for a simple AtomicInteger or a lock‑protected map and be done. In a microservice world, each instance runs in its own container or VM, so that in‑memory counter is now per‑instance. Without coordination, you’ll end up with a limit that’s N times higher than you intended (where N = number of instances).

The fix? Move the shared state out of the process and into a fast, centrally‑accessible store—think Redis, DynamoDB, or even a dedicated consensus cluster. The limiter itself can stay tiny; it just talks to the store to check and update a token bucket or a fixed‑window counter.

That shift changes the trade‑offs dramatically:

Aspect Monolith (in‑memory) Microservice + External Store
Complexity Low – just a few lines of sync code Higher – need client library, handling connection failures
Latency Sub‑microsecond (local) ~1‑2 ms round‑trip to Redis (still cheap)
Scalability Limited by single process CPU/memory Horizontally scale limiter instances; store can be sharded
Fault Tolerance If the process dies, limiter state is lost State persists; can recover with replication
Operational Overhead None Need to monitor, tune, and backup the store

The “aha!” moment was realizing that you don’t have to choose monolith vs microservices for the whole app—you can pick the right deployment model per concern. For a rate limiter, the externalized state pattern wins once you have more than a handful of instances, even if the rest of your system stays monolithic.

Wielding the Power (Code & Examples)

The Struggle: Naïve In‑Memory Limiter (Monolith)

// Bad idea when you later scale out
@Component
public class SimpleRateLimiter {
    private final Map<String, AtomicInteger> counters = new ConcurrentHashMap<>();
    private final long limit;
    private final long windowMs;

    public SimpleRateLimiter(long limit, long windowMs) {
        this.limit = limit;
        this.windowMs = windowMs;
    }

    public boolean allow(String key) {
        AtomicInteger cnt = counters.computeIfAbsent(key, k -> new AtomicInteger(0));
        int current = cnt.incrementAndGet();
        if (current > limit) {
            return false;
        }
        // reset after window (simplistic)
        ified)
        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Trap #1 – Each JVM gets its own counters map. With three instances, a client could slip through 3 × limit requests before any instance sees the overflow.

Trap #2 – The reset logic is racy; you can easily lose counts or double‑reset under load.

The Victory: Redis‑Backed Token Bucket (Microservice‑Friendly)

We’ll expose a tiny gRPC/HTTP service called rate-limiter-svc. It receives a Check(key) request, runs a Lua script atomically in Redis, and replies ALLOW or DENY.

Lua script (token bucket) – runs atomically, no race conditions:

-- KEYS[1] = redis key for this identifier (e.g., "rate:user:123")
-- ARGV[1] = capacity (max tokens)
-- ARGV[2] = token refill rate per second
-- ARGV[3] = now (unix time ms)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

local last = tonumber(redis.call("HGET", key, "last") or "0")
local tokens = tonumber(redis.call("HGET", key, "tokens") or capacity)

-- refill based on elapsed time
local delta = math.max(0, (now - last) / 1000.0)
tokens = math.min(capacity, tokens + delta * refill)

local allowed = tokens >= 1
if allowed then
    tokens = tokens - 1
end

redis.call("HMSET", key, "tokens", tokens, "last", now)
-- expire after 2×window to auto‑clean idle keys
redis.call("EXPIRE", key, 86400)

return allowed and 1 or 0
Enter fullscreen mode Exit fullscreen mode

Java client snippet (the service that talks to Redis):

@Service
public class RateLimiterService {
    private final RedisTemplate<String, String> redis;
    private final String luaSha; // SHA of the script above, loaded once

    public RateLimiterService(RedisTemplate<String, String> redis,
                              RedisScript<Long> script) {
        this.redis = redis;
        this.luaSha = script.getSha1();
    }

    public boolean allow(String key, long capacity, double refillPerSec) {
        Long now = System.currentTimeMillis();
        Long allowed = redis.execute(
                new DefaultRedisScript<>(luaSha, Long.class),
                List.of(key),
                String.valueOf(capacity),
                String.valueOf(refillPerSec),
                String.valueOf(now)
        );
        return allowed == 1;
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this beats the naïve version

  • Atomicity – The Lua script guarantees that the read‑refill‑consume‑write happens as one indivisible operation, so no two threads (or service instances) can oversubscribe.
  • Decoupled State – All instances of rate-limiter-svc share the same Redis dataset; scaling the limiter service out doesn’t multiply the limit.
  • Observability – Redis gives you INFO, MONITOR, and latency metrics for free; you can alert when the store approaches saturation.
  • Failure Modes – If Redis is unreachable, the service can fail‑open (allow traffic) or fail‑closed (reject) based on your business needs—something you can’t easily do with a pure in‑memory map that just disappears when the JVM crashes.

Quick ASCII Diagram

+-------------------+          +---------------------+
|   API Gateway     |  -->     | Rate Limiter Svc    |
+-------------------+          +----------+----------+
                                          |
                               +----------v----------+
                               |      Redis Cluster  |
                               +---------------------+
Enter fullscreen mode Exit fullscreen mode

In a monolith you’d collapse the middle two boxes into one process; the arrow to Redis stays the same, but the “Rate Limiter Svc” box disappears into the API handler.

Why This New Power Matters

Now you can:

  • Scale your API tier without worrying that each new instance silently raises the effective request ceiling.
  • Roll out rate‑limit policy changes (different limits per user tier, dynamic burst size) by merely updating the Lua script or a config map—no service restart needed.
  • Leverage existing ops tooling—your team already monitors Redis; you get dashboards, backups, and replication for free.
  • Keep the rest of your system simple. If the majority of your app still feels comfortable as a monolith, you can keep it that way and only extract the limiter when the traffic pattern demands it.

I felt like a superhero when the Lua script returned 1 for the first time under a synthetic load test—requests were being shut off exactly at the threshold, no more, no less. It was the kind of clean, deterministic behavior you dream of when debugging flaky rate limits.

Your Turn: Start Your Own Quest

Grab a Redis instance (Docker’s redis:7 works fine), drop the Lua script in, and write a thin wrapper around it in your favorite language. Test it with a tool like wrk or hey and watch the cutoff line appear sharp as a katana. Then ask yourself: What other cross‑cutting concerns (caching, feature flags, distributed locks) could benefit from the same “state‑outside‑the‑process” pattern?

If you try it, drop a comment with your surprise, your gotcha, or the moment you felt like Neo dodging bullets. Happy hacking! 🚀

Top comments (0)