System Design Case Study

Design rate limiting for a payments API where clients run batch settlements (sudden bursts) but must average 100 req/sec

➔? Design rate limiting for a payments API: allow controlled bursts for batch settlements while capping the average at 100 req/sec
Concepts Involved

Problem Statement

Design rate limiting for a payment API where merchants run batch settlements (e.g., 500 charges at once at end-of-day) as sudden bursts, but must average 100 requests/second over time. The system must deliberately allow controlled bursts while capping the sustained average rate.

Core challenge: The merchant's legitimate use case requires bursting. End-of-day batch settlement sends 500 payment requests in 5 seconds. A strict sliding window (100/sec) would reject 400 of those. You need an algorithm that accumulates capacity over idle periods and allows spending it in bursts — while still preventing sustained abuse above the average.
100
req/sec average
sustained rate
500
burst capacity
max instantaneous
Token Bucket
capacity vs refill
textbook use case
5s
to refill burst
500 / 100 = 5s

Functional Requirements

What the system must do · burst-tolerant rate limiting for payment APIs

Must Have (Core)

1. Allow controlled bursts up to bucket capacity (500 requests instantly)
2. Cap sustained average rate at 100 req/sec over any time period
3. Lazy refill (no background timer) · calculate on each request
4. Idempotency key exemption · retries with same key do NOT consume additional tokens
5. Per-merchant API key · each merchant has independent token bucket
6. Standard rate limit headers · X-RateLimit-Limit, Remaining, Burst, Retry-After

Out of Scope (this design)

Payment processing logic (charge, capture, refund flows)
Fraud detection (risk scoring engine)
Currency conversion (exchange rate service)
Multi-region distribution (geo-routing layer)
Per-endpoint differentiation (uniform rate across all payment endpoints)

Non-Functional Requirements

Quality constraints shaping the token bucket design

PropertyTargetWhy It Matters / Design Impact
BurstAllow up to 500 requests instantlyBatch settlement sends 500 charges at end-of-day. Must not be rejected.
AverageSustained rate ≤ 100 req/secLong-term throughput cap. Prevents sustained abuse above contracted rate.
RecoveryFull burst restored after 5s idle500 tokens / 100 per sec refill = 5 seconds to full capacity.
Latency<2ms per rate checkRedis atomic Lua script. Rate limit check must not add meaningful latency to payment flow.
IdempotencySame key = no additional token costPayment retries are legitimate. Charging a token for retries would unfairly penalize reliable clients.
Key tension: Burst Tolerance vs. Abuse Prevention. Allowing bursts means a malicious client could hammer the API with 500 requests instantly. Mitigation: capacity is finite (500), refill is slow (100/sec), and each merchant has a separate bucket. Abuse requires burning your own burst budget.

Scale Estimation

Derive infrastructure sizing from merchant traffic patterns

Given: 10K merchants · Average 50 req/sec each · Peak burst 500 req at end-of-day settlement · Bucket capacity 500, refill 100/sec
StepWhat to DeriveCalculationResultDesign Decision
1 Worst-case peak (all burst simultaneously) 10K merchants × 500 burst = 5M requests 5M (theoretical, unlikely) Staggered in practice. Settlement times vary by timezone and merchant.
2 Realistic peak 100 merchants settle at same time × 500 50K req/sec burst Redis handles easily. Horizontal sharding of rate limit keys if needed.
3 Redis memory for rate limits 10K keys × 2 fields (tokens, last_ts) × 50 bytes ~1MB total Trivial memory. Single Redis instance sufficient for rate limit state.
4 Refill calculation per request tokens_added = elapsed_since_last × 100 (capped at 500) O(1) per request Lazy refill: no background timer. Two fields stored, one calculation on each check.
5 Idempotency cache size 10K merchants × 100 req/sec × 24hr TTL × 200 bytes ~17GB for full cache Redis cluster with TTL eviction. Only store idempotency keys, not full responses.
Interview tip: Lead with the refill math (step 4) · it shows you understand lazy token bucket internals. Then mention idempotency exemption · it's the payment-specific twist that separates this from generic rate limiting. The 1MB memory footprint shows the solution is lightweight.

APIs

Payment API rate limiting interfaces and response headers

Token Bucket Middleware (All Payment Endpoints)

Applied to: All /v1/charges, /v1/refunds, /v1/payouts endpoints
Identification: API key in Authorization header → maps to merchant bucket
Response headers: X-RateLimit-Limit: 100, X-RateLimit-Burst: 500, X-RateLimit-Remaining: {tokens}
429 response: Retry-After: {(needed_tokens - available) / refill_rate} seconds

Idempotency Key Bypass

Header: Idempotency-Key: {unique_key}
Behavior: If same key seen before → return cached response, bypass rate limit
Cache TTL: 24 hours (covers retry windows for failed payments)
Key format: idempotency:{api_key}:{idem_key} → stored response

Data Model

Storage structures for token bucket state and idempotency cache

// Redis HASH per merchant: token bucket state
// Key: ratelimit:merchant:{api_key}
HSET ratelimit:merchant:sk_live_abc123 tokens 350 last_refill 1705312200.123

// Lua script (atomic check + refill + deduct):
-- 1. Read current tokens and last_refill timestamp
-- 2. Calculate: elapsed = now - last_refill
-- 3. Add tokens: new_tokens = min(capacity, tokens + elapsed * refill_rate)
-- 4. If new_tokens >= 1: deduct 1, update timestamp, return ALLOWED
-- 5. Else: return DENIED + time_until_next_token

// Idempotency cache
// Key: idempotency:{api_key}:{idempotency_key}
SET idempotency:sk_live_abc123:idem_xyz "{cached_response}" EX 86400

// Check flow:
// 1. Check idempotency cache → if hit, return cached (no token cost)
// 2. Check token bucket → if denied, return 429
// 3. Process request → store in idempotency cache → return response

Architecture · Token Bucket (Capacity vs Refill Rate)

Bucket capacity = burst size. Refill rate = average rate. Idle periods accumulate tokens for burst spending.

Token Bucket · Burst Capacity + Average Rate Control TOKEN BUCKET Capacity: 500 tokens (max burst) 350 tokens Available for immediate use After 3.5s idle: 350 tokens accumulated 150 empty slots (refilling at 100/sec) Refill: +100 tokens/sec (= sustained average rate) Capped at capacity 500 -1 token per request SCENARIOS · How Token Bucket Handles Bursts Batch Settlement (after 5s idle) 500 tokens saved → send 500 instantly ✓ Steady Traffic (100/sec sustained) Bucket stays near 0, refill = drain ✓ Burst Exceeds Capacity (600 req) 500 allowed, 100 get 429 + Retry-After ✗ Token Accumulation Over Time t=0 (burst) 0 tokens t=1s 100 tokens t=2s 200 tokens t=3s 300 tokens t=5s 500 (FULL) t=10s 500 (capped) Key Parameters: Capacity = 500 (burst) Refill = 100/sec (average) Recovery = 5s to full
DecisionChoiceWhy
AlgorithmToken bucket: capacity=500, refill_rate=100/secAllows instant burst of 500. After burst, limited to 100/sec until bucket refills. Average never exceeds 100/sec.
Capacity (burst size)500 tokens (5 seconds of accumulated capacity)Matches batch settlement size. Merchant can send 500 at once after 5+ seconds of inactivity.
Refill rate100 tokens per secondThis IS the average rate. After a burst, bucket refills at 100/sec. Sustained traffic cannot exceed this.
ImplementationLazy refill: on each request, calculate tokens_added = elapsed_since_last × refill_rateNo background timer needed. O(1) per request. Store only: {token_count, last_request_time}.
StorageRedis: HASH with tokens_remaining + last_refill_ts per API keyAtomic check-deduct via Lua. Shared across all app servers.
HeadersX-RateLimit-Limit: 100, X-RateLimit-Remaining: {tokens}, X-RateLimit-Burst: 500Client knows both their sustained limit and remaining burst capacity.
Token bucket mechanics: Bucket starts full at 500 tokens. Each request removes 1 token. Every second, 100 tokens are added (up to max 500). After a 500-request burst: bucket = 0. Must wait 1 second to get 100 more tokens. Full burst capacity restored after 5 seconds idle.
Capacity vs refill rate — the key insight: Capacity = maximum burst size (how many requests can fire instantly). Refill rate = sustained average (long-term throughput). These are two independent knobs. You can have high burst with low average, or low burst with high average.
Why NOT sliding window: Sliding window counter says "100 per second" — period. It rejects request #101 in any 1-second span, even if the previous 10 seconds were idle. No burst tolerance. The batch settlement would be rejected. Token bucket is the correct choice when bursts are legitimate.
Real-world: Stripe · 100 req/sec with 200 burst (token bucket). AWS API Gateway · token bucket with configurable burst + rate. Shopify · leaky bucket with 40-request bucket size. PayPal · per-endpoint rate with burst allowance.

Resilience & Edge Cases

FailureImpactRecovery
Merchant sends burst larger than capacityRequests beyond 500 get 429 immediatelyClient-side batching: split into chunks of 500, wait refill time between chunks. Return Retry-After header with exact wait time.
Sustained traffic at exactly 100/sec (no idle)Bucket never refills above 100, no burst availableCorrect behavior. Sustained rate is the cap. If merchant needs burst + sustained, increase capacity (plan upgrade).
Clock drift in lazy refillIncorrect token calculationsUse Redis TIME for consistent clock. Cap refill at capacity (prevent overflow on large time gaps).
Concurrent requests drain bucket simultaneouslyRace condition: both see tokens=1, both deductAtomic Lua script: read + deduct + return new level in one operation.
Payment processing requires idempotencyRetried requests shouldn't double-chargeIdempotency keys bypass rate limiting for retries (same idempotency key = same request, don't deduct again).

Interview Cheat Sheet

Key points for burst-allowing rate limiting

1. Token bucket · capacity (burst) + refill rate (average) are two independent parameters
2. Capacity = max burst · how many requests can fire instantly after idle period
3. Refill rate = sustained average · long-term throughput cannot exceed this
4. Lazy refill · no timer needed, calculate on each request: elapsed × rate (cap at capacity)
5. Keyword: "allow bursts but cap average" · this is the textbook token bucket signal
6. Idempotency key exemption · retried payments with same key don't consume additional tokens