Distributed API Rate Limiter Interview Questions | JiQuest

add

#

Distributed API Rate Limiter

Mini project / POC · infrastructure

Distributed API Rate Limiter: a complete Spring Cloud Gateway mini project.

Token bucket vs. sliding window algorithms, atomic Redis Lua scripting that eliminates the classic check-then-increment race condition, multi-dimensional limiting by API key and route, and an honest answer to "what happens when Redis is down."

2Services
4Algorithms compared
1Atomic Lua script
Client Client(concurrent) Gatewayrate-limit filter Redisatomic counters Backend API Policy Servicetiers & limits every Gateway instance shares one counter, not one each

Project overview and requirements

"Design a rate limiter" is one of the most asked system-design questions there is, and most candidates can describe an algorithm but stumble on the part that actually matters: making it correct when the limiter runs as multiple horizontally-scaled instances sharing one counter. This project builds that correctly, not just conceptually.

Functional requirements

Per-client limitsEach API key is limited according to its tier (FREE / PRO / ENTERPRISE).
Per-route limitsA sensitive route (like login) can carry a stricter limit than a general read endpoint.
Standard rejection contractOver-limit requests get 429, a Retry-After header, and remaining-quota headers.
Configurable without redeployAn admin can change a tier's limit and have it take effect within seconds.

Non-functional requirements

Correct under concurrencyN simultaneous requests against a limit of N never let N+1 through.
Correct across instancesThe limit holds globally, whether the Gateway runs as 1 instance or 50.
Fails predictablyA Redis outage has one deliberately chosen behavior, not undefined behavior.
Low overheadThe limiter check adds roughly one Redis round trip, not a chain of them, per request.

Technology stack and why each piece is there

TechnologyRole in this projectWhy this one
Java 21 + Spring Boot 3.xRuntime and framework for both services.Same reasoning as the other three mini projects.
Spring Cloud GatewayHosts the rate-limit filter on every request's critical path.A reactive, non-blocking gateway is the natural place to reject a request before it ever reaches a backend service.
RedisThe single shared counter store every Gateway instance reads and writes.Sub-millisecond in-memory operations, and critically, server-side Lua scripting for atomic check-and-increment.
PostgreSQLPolicy Service's durable store for tiers, limits, and API keys.Rate-limit policy is low-volume, relational, admin-managed data — a normal database, not Redis, is the right home for it.
Spring Security + JWTProtects the Policy Service's admin endpoints.Same JWT/RBAC pattern as the other mini projects — only ADMIN can change limits.
Notably absent: Kafka. The other three mini projects all lean on Kafka for cross-service events; this one deliberately doesn't need it. Policy changes are infrequent and can propagate through a short-TTL cache instead of an event bus — reaching for Kafka here would add machinery the problem doesn't actually call for.

Jump to a section

High-level architecture

The Gateway is stateless and horizontally scaled — that's the whole reason the counter can't live in its memory. Redis is the one thing every Gateway instance agrees on, which is what makes "5 requests per second, globally" actually mean globally instead of "5 requests per second, per instance."

Clients Gateway instance 1stateless Gateway instance 2stateless Gateway instance Nstateless Redisone shared counter store Backend servicesonly see allowed traffic Policy Servicepolicy_db, tiers & limits Gateway polls/caches policy; only ADMIN calls Policy Service directly to change it

Service breakdown

API Gateway — where the rate limiter actually lives

A Spring Cloud Gateway instance with a custom GlobalFilter that runs before every proxied request. It resolves the caller's API key and route, looks up the applicable policy (from a short-TTL local cache backed by Policy Service), and executes one atomic Redis Lua script to decide allow or reject.

  • routes all proxied traffic to downstream backend services
  • rejects over-limit requests with 429 before they reach any backend
  • caches policies locally (Caffeine, short TTL) to avoid a Policy Service call per request
Stateless, horizontally scaledTalks to Redis on every requestNo database of its own

Policy Service — tiers, limits, and API keys

Owns the relational configuration data: which tier an API key belongs to, and what limit and window each tier/route combination gets. This data changes rarely (an admin action), so it's a normal CRUD service backed by Postgres, not something that needs to sit on the hot request path.

  • GET /policies — Gateway, fetches current policy set (cached locally after)
  • POST /policies, PATCH /policies/{id} — ADMIN only
  • POST /api-keys — ADMIN, issues a new API key for a client at a given tier
Owns policy_dbLow request volume, not on the hot pathJWT/RBAC protected

Rate limiting algorithms compared

There isn't one universally correct algorithm — each makes a different trade-off between memory cost, accuracy, and whether it allows short bursts. This project implements token bucket by default but the Redis key design supports swapping in any of them per route.

AlgorithmHow it worksTrade-off
Fixed window counterCount requests in a hard time window (e.g. 00:00–00:59), reset to zero at the boundary.Simplest and cheapest, but allows up to 2x the limit in a burst spanning a window boundary (a full quota just before 00:59, and another full quota just after 01:00).
Sliding window logStore the timestamp of every request in a sorted set; count entries within the trailing window.Perfectly accurate, but memory grows with request volume — expensive at high throughput.
Sliding window counterWeight the previous fixed window's count by how much of it overlaps the current sliding window.Close approximation of the sliding log at fixed-window memory cost — the common production compromise.
Token bucket (used here)A bucket holds up to N tokens, refills at a steady rate, each request consumes one token.Allows a controlled burst up to the bucket size while still enforcing a steady-state average rate — matches real client behavior better than a hard window.
Bucket (capacity 10) 6 tokens available now refills at 2 tokens/sec, up to capacity Request arrives token available: allowconsume 1 token bucket empty: reject429 + Retry-After

The race condition, and how atomic Lua scripting fixes it

This is the part almost every "design a rate limiter" answer glosses over. Doing the token-bucket check and the token-bucket update as two separate Redis calls from the application is broken under concurrency — and it's the single most important correctness detail in this entire project.

Broken: two separate Redis calls (GET then SET) Request A Request B GET tokens → reads 1 GET tokens → also reads 1 (before A's SET lands) both see "1 token available" → both ALLOW result: 2 requests allowed on a 1-token budget Fixed: one atomic Lua script (EVAL) Request A Request B EVAL runs check+decrement as one indivisible operation Redis's single-threaded execution model serializes A and B automatically A: allowed (1→0) B: rejected (0 left)
-- token_bucket.lua
-- KEYS[1] = bucket key, ARGV[1] = capacity, ARGV[2] = refill rate/sec, ARGV[3] = now (epoch seconds)
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or tonumber(ARGV[1])
local lastRefill = tonumber(bucket[2]) or tonumber(ARGV[3])

local elapsed = tonumber(ARGV[3]) - lastRefill
local refill = math.min(tonumber(ARGV[1]), tokens + elapsed * tonumber(ARGV[2]))

if refill >= 1 then
    redis.call('HMSET', KEYS[1], 'tokens', refill - 1, 'last_refill', ARGV[3])
    redis.call('EXPIRE', KEYS[1], 3600)
    return 1          -- allowed
else
    redis.call('HMSET', KEYS[1], 'tokens', refill, 'last_refill', ARGV[3])
    redis.call('EXPIRE', KEYS[1], 3600)
    return 0           -- rejected
end
// RateLimiterFilter.java
@Component
public class RateLimiterFilter implements GlobalFilter, Ordered {

    private final ReactiveRedisTemplate redis;
    private final RedisScript tokenBucketScript;   // loads token_bucket.lua

    @Override
    public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String apiKey = exchange.getRequest().getHeaders().getFirst("X-API-Key");
        RateLimitPolicy policy = policyCache.forKeyAndRoute(apiKey, exchange.getRequest().getPath().value());
        String bucketKey = "ratelimit:{" + apiKey + "}:" + policy.routeGroup();

        return redis.execute(tokenBucketScript,
                List.of(bucketKey),
                List.of(String.valueOf(policy.capacity()), String.valueOf(policy.refillPerSecond()), String.valueOf(nowEpochSeconds())))
            .single()
            .flatMap(allowed -> allowed == 1L
                ? chain.filter(exchange)
                : reject(exchange, policy));
    }
}
Why this is safe without application-level locking Redis executes commands (and an entire Lua script) single-threaded, one at a time, per node — while EVAL is running, no other command touches that key. That guarantee, not anything in the Java code, is what makes the check-and-decrement atomic.

Multi-dimensional limiting: Redis key design

A real API needs more than one limit dimension — per API key, per route, sometimes per IP as a secondary defense. Each dimension is just a different Redis key built from the same script.

DimensionKey patternPurpose
Per API key + routeratelimit:{apiKey}:route:/ordersThe primary business limit — this client's plan allows N requests/sec to this endpoint.
Per API key, globalratelimit:{apiKey}:globalAn overall ceiling regardless of which routes the client hits.
Per IPratelimit:{clientIp}:ipA coarse, identity-independent defense against abuse from an unauthenticated or spoofable source.

A single request can be checked against more than one bucket (both the per-key and per-IP limits), and is rejected if any applicable bucket is empty — the same Lua script, called once per dimension that applies.

The {apiKey} hash tag matters in Redis Cluster Redis Cluster shards keys across nodes by hashing the key, but a Lua script can only atomically touch keys that live on the same node. Wrapping the variable part of the key in {} (a hash tag) forces Redis to hash only that substring, guaranteeing every key for one API key lands on the same node — without it, the script would fail cross-slot in a clustered deployment.

Security and role-based access control

Two different identities matter here: the API key being rate-limited (a client's traffic identity) and the JWT-authenticated admin managing policies (a human's identity). They're deliberately separate concerns.

RoleCan do
API key (any tier)Make requests through the Gateway; subject to its tier's limits. Not a JWT-authenticated identity.
ROLE_ADMIN (JWT)Create/modify rate-limit policies and issue new API keys via Policy Service.
Security note An unrecognized or missing API key should get the strictest anonymous-tier limit, never an unlimited default — "fail toward more restrictive" is the safe default for identity resolution, the mirror image of the fail-open/fail-closed question for Redis itself, covered next.

Request sequence and policy propagation

Client Gateway Redis Backend Admin 1. request + X-API-Key 2. resolve policy (from local cache) 3. EVAL token_bucket.lua 4. allowed / rejected 5a. allowed → proxy the request 5b. rejected → 429 + Retry-After 6. admin changes a tier's limit via Policy Service — Gateway's cache picks it up within its TTL, no redeploy

When Redis goes down: fail-open vs. fail-closed

The rate limiter has a single point of dependency on Redis, and what happens during a Redis outage is a real, consequential design decision — not an afterthought.

Fail-openIf Redis is unreachable, allow the request through unlimited. Protects availability of the product; risks an abuse or cost spike exactly when you can't measure it.
Fail-closedIf Redis is unreachable, reject all requests. Protects backend services from an unbounded flood; takes your entire API down on a Redis blip.
The choice this project makes, and why Fail-open, with a tight circuit-breaker timeout (so a slow Redis doesn't also slow down every request) and a loud alert the moment it triggers. An availability outage caused by your own rate limiter is a worse failure mode for most APIs than a brief, monitored, unlimited window — but a payments or auth API might reasonably choose the opposite, which is exactly why this should be a documented decision, not a default nobody consciously made.
return redis.execute(tokenBucketScript, keys, args)
    .single()
    .timeout(Duration.ofMillis(50))              // fail fast, don't let Redis latency become request latency
    .onErrorReturn(1L)                            // fail OPEN: treat any Redis error as "allowed"
    .flatMap(allowed -> allowed == 1L ? chain.filter(exchange) : reject(exchange, policy));

Project folder structure

api-rate-limiter-poc/
├── api-gateway/
│  ├── src/main/java/com/jiquest/gateway/
│  │  ├── GatewayApplication.java
│  │  ├── filter/RateLimiterFilter.java
│  │  ├── filter/ResponseHeaderFilter.java   // X-RateLimit-* headers
│  │  ├── config/RedisConfig.java            // loads token_bucket.lua as a RedisScript bean
│  │  ├── policy/PolicyCache.java, PolicyClient.java
│  │  └── policy/RateLimitPolicy.java
│  ├── src/main/resources/application.yml
│  ├── src/main/resources/scripts/token_bucket.lua
│  ├── Dockerfile
│  └── pom.xml
│
├── policy-service/
│  ├── src/main/java/com/jiquest/policy/
│  │  ├── PolicyServiceApplication.java
│  │  ├── config/SecurityConfig.java
│  │  ├── controller/PolicyController.java, ApiKeyController.java
│  │  ├── service/PolicyService.java, ApiKeyService.java
│  │  ├── repository/PolicyRepository.java, ApiKeyRepository.java
│  │  └── entity/RateLimitPolicy.java, ApiKeyEntity.java
│  ├── src/main/resources/application.yml
│  ├── src/test/java/com/jiquest/gateway/ConcurrentLimitIT.java
│  ├── Dockerfile
│  └── pom.xml
│
├── docker-compose.yml
├── .env.example
└── README.md

Local development setup

# docker-compose.yml (excerpt)
services:
  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]

  postgres-policy:
    image: postgres:16
    environment: { POSTGRES_DB: policy_db, POSTGRES_PASSWORD: postgres }
    ports: ["5433:5432"]

  policy-service:
    build: ./policy-service
    depends_on: [postgres-policy]
    ports: ["8081:8080"]

  api-gateway:
    build: ./api-gateway
    depends_on: [redis, policy-service]
    environment:
      SPRING_REDIS_HOST: redis
      POLICY_SERVICE_URL: http://policy-service:8080
    ports: ["8080:8080"]

Testing strategy: proving the limit actually holds

Like the payment project's concurrency test, the test that matters most here isn't a unit test of the Lua script's logic in isolation — it's firing real concurrent requests and proving the aggregate count never exceeds the configured limit.

@Testcontainers
class ConcurrentLimitIT {

    @Container
    static GenericContainer redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @Test
    void fiftyConcurrentRequestsAgainstALimitOfTenAllowExactlyTen() throws InterruptedException {
        ExecutorService pool = Executors.newFixedThreadPool(50);
        CountDownLatch latch = new CountDownLatch(50);
        AtomicInteger allowed = new AtomicInteger();

        for (int i = 0; i < 50; i++) {
            pool.submit(() -> {
                if (rateLimiter.tryConsume("test-key", "route:/orders")) {
                    allowed.incrementAndGet();
                }
                latch.countDown();
            });
        }
        latch.await(10, TimeUnit.SECONDS);

        assertThat(allowed.get()).isEqualTo(10);   // never 11, regardless of thread interleaving
    }
}
Testcontainers Redis50-thread concurrent load testFail-open path tested with a killed Redis container

Observability

Allow/reject rate per API keyA client consistently hitting their ceiling is either a signal to upsell them to a higher tier or a sign of runaway retry behavior on their end.
Redis latency for the EVAL callSince this sits on every request's critical path, a latency regression here directly becomes API latency.
Fail-open trigger countShould be zero. Any non-zero count means Redis had an availability blip and unlimited traffic passed through during it.
Policy cache hit rateA low hit rate means the TTL is too short or too many distinct API key/route combinations are churning the cache.

Path to production on AWS

Local (this POC)AWS equivalent
Single Redis containerAmazon ElastiCache for Redis, cluster mode enabled for scale (hash-tagged keys still required)
N Gateway containers behind Docker ComposeECS Fargate or EKS, auto-scaled behind an ALB
Postgres container for policy_dbAmazon RDS PostgreSQL — low volume, doesn't need Multi-AZ as urgently as the payment project's ledger did

Key design decisions and interview talking points

Why can't each Gateway instance just keep its own in-memory counter?

With an in-memory counter and 5 Gateway instances behind a load balancer, a client sending requests that get spread across all 5 would effectively get 5x the intended limit — each instance only sees and limits its own slice of traffic. A shared external store is what makes "N requests per second" mean N in total, not N per instance.

Why token bucket instead of the sliding window log, which is more accurate?

Sliding window log's accuracy comes from storing every request's timestamp, which costs memory proportional to request volume — expensive at real API scale. Token bucket needs only two numbers per key (current tokens, last refill time) regardless of traffic volume, and its burst-tolerance behavior actually matches how real clients behave (occasional bursts, not perfectly metronomic traffic) better than a hard window does.

What exactly goes wrong with a naive GET-then-SET implementation, concretely?

Two concurrent requests can both execute GET before either executes SET, so both read the same "1 token available" state and both proceed to allow the request — the second request's decrement overwrites, rather than compounds, the first's. The window for this race can be microseconds, but under real concurrent load it happens constantly, not rarely.

Why does the fix have to be a server-side Lua script instead of a client-side Redis transaction (MULTI/EXEC)?

Redis transactions queue commands and execute them atomically, but a transaction can't make a decision based on a value it just read within the same transaction — you'd still need to read the value first, outside the transaction, to decide whether to proceed, reopening the same race. A Lua script runs entirely server-side, so the read, the decision, and the write happen as one atomic unit with no gap for another client to interleave.

How do you rate-limit by both API key and IP without one dimension unfairly penalizing shared infrastructure?

The per-API-key limit is the primary, fair business limit tied to a specific client's plan; the per-IP limit is a coarser, secondary defense meant to catch abuse from unauthenticated or unidentified traffic, and is typically set much higher so it rarely affects legitimate traffic from behind a shared NAT or corporate proxy where many real users share one IP.

Why cache policies in the Gateway locally instead of calling Policy Service on every request?

Policy Service sits outside the hot path specifically so a slow or unavailable policy lookup doesn't become added latency (or a new failure mode) on every single proxied request. A short-TTL local cache means policy changes still propagate within seconds, which is more than fast enough for a limit change, while removing Policy Service from the critical path entirely.

What's the Redis Cluster hash-tag issue, and why would the Lua script break without it?

Redis Cluster shards keys across nodes based on a hash of the key, and a Lua script (or any multi-key command) can only run atomically across keys that resolve to the same node. Without a {apiKey} hash tag forcing colocation, two keys for the same client could land on different nodes, and Redis would refuse the script with a cross-slot error in cluster mode — it works by accident on a single-node Redis and breaks the moment you cluster it.

Why include a Retry-After header instead of just returning a bare 429?

A bare 429 tells a well-behaved client it was rejected but not when to safely retry, so it either retries immediately (making the problem worse) or backs off arbitrarily. Retry-After (and the X-RateLimit-Remaining/Reset headers) let a well-behaved client's own retry logic cooperate with the limiter instead of guessing.

How would clock skew across Gateway instances affect correctness, given the Lua script takes "now" as an argument?

Each Gateway instance passes its own local clock's epoch time into the script, so meaningful skew between instances could let a client get a slightly different effective refill rate depending on which instance handled which request. This is why the script's "now" parameter should ideally come from Redis's own TIME command instead of the caller's clock — a refinement worth making before this leaves POC stage.

Is rate limiting the same thing as backpressure or throttling?

They're related but distinct: rate limiting rejects excess requests outright once a threshold is crossed, a hard admission-control decision at the edge. Backpressure is a downstream system signaling it's overloaded so an upstream caller slows down or queues, and throttling often refers to deliberately slowing (not rejecting) requests to smooth load. This project implements rate limiting specifically — rejection, not queuing or slowing.

How would you test that the limiter is actually atomic, rather than just trusting the Lua script's logic?

A unit test of the script's branching logic proves the algorithm is correct in isolation, but says nothing about concurrent correctness; the real proof is firing many concurrent requests (as in the testing section above) against a real Redis instance and asserting the allowed count exactly matches the limit, which would fail immediately if the atomicity guarantee were somehow broken.

What happens if Redis itself becomes the bottleneck at very high request volume?

A single Redis node has a throughput ceiling; the standard mitigations are Redis Cluster (sharding keys, using the hash-tag technique above to keep each client's keys atomic-safe), or accepting a slightly weaker consistency model (a local-first counter with periodic async sync to Redis) that trades perfect global accuracy for much higher throughput — a real trade-off to make only once you've measured that Redis, not something else, is actually the bottleneck.

Why does an unrecognized API key get the strictest tier instead of being rejected outright or given a generous default?

Rejecting outright would break legitimate new clients mid-provisioning; a generous default would let a malformed or missing key accidentally bypass rate limiting entirely. Defaulting to the strictest anonymous tier fails toward safety in both directions — it degrades gracefully rather than either blocking valid traffic or exposing an unlimited loophole.

Why is Policy Service a separate service instead of just a config table the Gateway reads directly?

Separating it gives policy management its own access control, audit trail, and API independent of the Gateway's deploy lifecycle — an admin can safely change a limit without touching or redeploying the Gateway at all, and the same service could later serve a billing dashboard or usage-reporting feature reusing the same policy and API-key data.

If an interviewer asks for the single most important design decision in this project, what do you say?

Making the check-and-decrement operation atomic via a server-side Lua script rather than two separate client-side Redis calls — it's the one decision that determines whether the rate limiter is actually correct under concurrency or just looks correct in a single-threaded demo, which is exactly the gap between a textbook algorithm description and a production-grade implementation.

Related guides

Update these hrefs to your published Blogger post URLs once each page is live.

No comments
Leave a Comment