Thread-Safe Rate Limiter
Difficulty: Problems | Track: LLD
Problem statement
Design a thread-safe rate limiter whose primary API is boolean allow(String userId). The limiter should be configurable with a request limit N and a time window T, support multiple interchangeable algorithms (token bucket, sliding window, fixed window), and work correctly when called concurrently from many threads. The design should make algorithm choice a run-time decision and keep the implementation testable without relying on wall-clock time.
Requirements
Functional:
allow(userId)returnstrueif the request is within the configured limit,falseotherwise- Configurable rate: N requests per T seconds, independently per user or API key
- Support at least two algorithms: Token Bucket and Sliding Window Log
- Thread-safe under concurrent access — no double-counts or missed decrements
Out of scope:
- Distributed rate limiting across multiple nodes (focus on single-process, in-memory)
- Persistent storage or Redis-backed counters
Key classes
// Strategy interface
interface RateLimiter {
boolean allow(String userId);
}
// Configuration value object
class RateLimiterConfig {
int maxRequests; // N
Duration windowSize; // T
}
// Token Bucket — thread-safe implementation
class TokenBucketRateLimiter implements RateLimiter {
private final RateLimiterConfig config;
private final Clock clock; // injected for testability
private final Map<String, TokenBucket> buckets = new ConcurrentHashMap<>();
@Override
public boolean allow(String userId) {
TokenBucket bucket = buckets.computeIfAbsent(userId, id -> new TokenBucket(config, clock));
return bucket.tryConsume();
}
}
class TokenBucket {
private final RateLimiterConfig config;
private final Clock clock;
private double tokens;
private long lastRefillTime;
private final ReentrantLock lock = new ReentrantLock();
boolean tryConsume() {
lock.lock();
try {
refill();
if (tokens >= 1) { tokens--; return true; }
return false;
} finally { lock.unlock(); }
}
private void refill() {
long now = clock.millis();
double elapsed = (now - lastRefillTime) / 1000.0;
tokens = Math.min(config.maxRequests,
tokens + elapsed * config.maxRequests / config.windowSize.toSeconds());
lastRefillTime = now;
}
}
// Fixed Window
class FixedWindowRateLimiter implements RateLimiter {
private final RateLimiterConfig config;
private final Clock clock;
private final Map<String, AtomicLong> counters = new ConcurrentHashMap<>();
private final Map<String, AtomicLong> windowStart = new ConcurrentHashMap<>();
@Override
public boolean allow(String userId) { /* reset counter if window expired */ return false; }
}
// Sliding Window Log
class SlidingWindowRateLimiter implements RateLimiter {
// stores per-user deque of request timestamps; prunes entries older than T
@Override
public boolean allow(String userId) { /* prune + size check */ return false; }
}
// Factory / registry
class RateLimiterFactory {
static RateLimiter create(String algorithm, RateLimiterConfig config, Clock clock) {
return switch (algorithm) {
case "token_bucket" -> new TokenBucketRateLimiter(config, clock);
case "fixed_window" -> new FixedWindowRateLimiter(config, clock);
case "sliding_window" -> new SlidingWindowRateLimiter(config, clock);
default -> throw new IllegalArgumentException("Unknown algorithm: " + algorithm);
};
}
}
Patterns used
- Strategy:
RateLimiteris an interface; the algorithm is selected at construction time and the call site (allow(userId)) never knows which one is running — swapping algorithms requires no code change at the call site - Template Method: a base class
AbstractRateLimitercan implement the per-user bucket lookup and delegate toisAllowed(bucket), so subclasses only implement the core acceptance logic without repeating the map lookup - Dependency Injection:
Clockis injected rather than callingSystem.currentTimeMillis()directly — tests pass aFakeClockthat advances on demand, making time-dependent assertions fully deterministic
Key design decisions
AtomicLongfor counters where possible:FixedWindowRateLimiterusesAtomicLong.incrementAndGet()for the happy path, avoiding lock acquisition entirely —ReentrantLockis only needed inTokenBucketbecause the read-modify-write on two fields (tokens+lastRefillTime) must be atomic togetherConcurrentHashMap.computeIfAbsent()for per-user buckets: avoids a race where two threads simultaneously find no bucket and both try to create one —computeIfAbsentguarantees exactly one bucket per user- Inject
Clock, notSystem.currentTimeMillis(): calling system time directly makes tests non-deterministic and slow (you'd have to actually sleep to advance the window); aClockinterface with a real impl and a fake impl solves this cleanly TokenBucketper user, not a shared global bucket: a single shared bucket would conflate all users; per-user buckets stored in aConcurrentHashMapgive independent rate windows at the cost of O(users) memory — acceptable for in-memory scope
What the interviewer is looking for
- Immediately spotting that thread-safety is the central challenge, not just the algorithmic logic
- Choosing
ReentrantLockvsAtomicLongconsciously and explaining the trade-off - Proposing
Clockinjection unprompted (or when asked "how would you test this?") - Understanding the trade-offs between algorithms: Token Bucket smooths bursts, Sliding Window is most accurate but memory-heavy, Fixed Window is cheapest but allows boundary bursts