This article explores the architectural decision of extracting a rate limiter from a monolithic application into a dedicated microservice. It highlights the challenges of in-process rate limiting, such as resource contention and lack of scalability, and demonstrates how a decoupled service offers better isolation, independent scaling, and improved observability for cross-cutting concerns like rate limiting.
Read original on Dev.to #systemdesignThe article begins by describing a common pitfall: implementing a rate limiter as an in-process component within a monolithic API service. Initially, this might seem simpler, but it quickly leads to issues under load due to shared resources. When the application experiences a traffic surge, the rate limiter—which uses shared memory and CPU—becomes a bottleneck, impacting the entire service's performance and stability.
The core insight is that rate limiting is a cross-cutting concern that benefits significantly from architectural decoupling. Treating it as a utility service, similar to DNS or a load balancer, allows it to operate independently of the main business logic. This separation addresses several critical system design challenges inherent in monolithic approaches.
// rateLimiter.go – lives inside the API service
var (
mu sync.RWMutex
counts = make(map[string]int) // key = clientID
limit = 100
window = time.Minute
)
func Allow(clientID string) bool {
mu.Lock()
defer mu.Unlock()
// ... naive reset logic ...
if counts[clientID] >= limit {
return false // throttled
}
counts[clientID]++
return true
}Monolithic Traps
The in-process monolithic implementation typically suffers from contention (e.g., mutex around a shared map), potential memory leaks, and the inability to scale horizontally effectively as each instance maintains its own independent state, leading to inconsistent rate limits.
// limiter-service.js
const express = require('express');
const redis = require('redis');
const client = redis.createClient();
const app = express();
const LIMIT = 100;
const WINDOW = 60; // seconds
app.get('/allow', async (req, res) => {
const key = `rl:${req.query.client}`;
const count = await client.incr(key);
if (count === 1) {
await client.expire(key, WINDOW); // set TTL on first hit
}
if (count > LIMIT) {
return res.status(429).json({allowed: false});
}
res.json({allowed: true});
});The microservice-style approach moves the rate limiting logic to a dedicated HTTP service, often backed by a fast, distributed data store like Redis. The main API service then makes a simple, lightweight HTTP call to this rate limiter service. This externalizes the state and logic, allowing the rate limiter to be horizontally scaled and managed independently, while also providing a single source of truth for rate limits across all API instances.