System Design Case Study

Design outbound rate limiting for an SMS service that must not exceed a carrier's fixed acceptance rate

➔? Design rate limiting for an SMS gateway where the downstream carrier accepts exactly 100 msgs/sec — no bursts allowed
Concepts Involved

Problem Statement

Design rate limiting for an outbound SMS service where the downstream carrier enforces a strict, fixed acceptance rate (100 messages/second). Bursts are not tolerated — the carrier will drop messages. The system must queue overflow, drain at a smooth constant rate, and handle backlog gracefully.

Core challenge: This is outbound rate limiting — you're protecting a downstream system, not your own. The carrier doesn't care about your traffic patterns; it will silently drop anything above 100 msg/sec. You must smooth output to a constant rate, queueing internally when inbound rate exceeds outbound capacity.
100
msg/sec max
carrier hard limit
No Burst
constant drain rate
smooth output required
Queue
internal buffer
absorb inbound spikes
Leaky Bucket
ideal algorithm
fixed output rate

Functional Requirements

What the system must do · outbound rate limiting for a fixed-rate downstream carrier

Must Have (Core)

1. Fixed constant output rate (no bursts) · exactly 100 msg/sec to carrier
2. Bounded FIFO queue to absorb inbound spikes (capacity: 10,000 messages)
3. Priority lanes for critical messages (OTP/2FA) · reserved drain capacity
4. TTL-based message expiry · stale messages removed before drain
5. Backpressure signal to upstream when queue full (HTTP 503)
6. Overflow to DLQ · expired/failed messages preserved for analysis

Out of Scope (this design)

Multi-carrier routing (separate routing layer)
Message content formatting (template engine)
Delivery confirmation/receipts (carrier callback handling)
Billing per message (billing service)
Geographic routing (number-to-carrier mapping)

Non-Functional Requirements

Quality constraints shaping the leaky bucket design

PropertyTargetWhy It Matters / Design Impact
Output RateEXACTLY 100 msg/secCarrier hard limit, no tolerance. Exceeding → messages silently dropped by carrier.
Queue Capacity10,000 messages100 seconds of buffer. Absorbs sustained bursts up to 25s at 500 msg/sec inbound.
PriorityOTP/2FA skip regular queueReserved 20% drain capacity (20 msg/sec) for critical messages. Never delayed by bulk.
LatencyVariable (depends on queue depth)Max: capacity / drain_rate = 10,000 / 100 = 100 seconds worst case.
Overflow503 to upstream when queue fullNever silently drop. Upstream callers get explicit backpressure signal + Retry-After.
Key tension: Throughput vs. Latency. Higher queue capacity absorbs longer bursts but increases worst-case delivery latency. A 10K queue means a message could wait up to 100s during sustained overload · OTP messages bypass this via priority lane.

Scale Estimation

Derive queue sizing and overflow timing from the given constraints

Given: Carrier limit 100 msg/sec · Average inbound 80 msg/sec · Peak 500 msg/sec during campaigns · Queue capacity 10,000 messages
StepWhat to DeriveCalculationResultDesign Decision
1 Queue fill rate during spike 500 (inbound) - 100 (drain) = 400 msg/sec accumulation 400 msg/sec net fill Queue absorbs difference between inbound and outbound rates
2 Time to fill queue 10,000 / 400 = 25 seconds of sustained peak 25 seconds before overflow Marketing campaigns must be time-boxed or rate-limited upstream
3 Memory for queue 10K messages × 1KB avg size ~10MB queue memory Easily fits in Redis or in-memory buffer on single node
4 Priority lane capacity 20% of 100 msg/sec drain reserved for OTP 20 msg/sec OTP, 80 msg/sec regular Critical messages always have guaranteed drain bandwidth
5 Time to drain full queue (no new input) 10,000 / 100 = 100 seconds 100s to fully drain Max delivery latency during recovery from sustained overload
Interview tip: Start with fill rate math (step 1-2) · this shows you understand the core tension. Then mention priority lanes (step 4) to show you protect critical messages. The 25-second overflow window is the key number interviewers want to hear.

APIs

External and internal interfaces for the SMS rate limiting service

POST /sms/send

Purpose: Enqueue an SMS message for delivery
Body: { to, from, body, priority: "critical"|"regular", ttl_seconds }
Response (success): 202 Accepted + { queue_position, estimated_delivery_seconds }
Response (queue full): 503 Service Unavailable + Retry-After: {seconds}

Internal: Timer-Based Drain

Mechanism: Dequeue 1 message every 10ms (= 100 msg/sec)
Priority interleave: Every 5th dequeue from critical lane (20% reserved)
TTL check: Before sending, verify message hasn’t expired → if expired, move to DLQ
Carrier call: HTTP POST to carrier API with message payload

Data Model

Storage structures for queue, priority lanes, and overflow handling

// Queue: Redis LIST (RPUSH to enqueue, LPOP to drain)
// Or in-memory bounded buffer for single-node deployment

RPUSH sms:queue:regular {message_json}    // Regular messages
RPUSH sms:queue:critical {message_json}   // OTP/2FA messages
LPOP sms:queue:critical                   // Drain critical first
LPOP sms:queue:regular                    // Then drain regular

// Message structure
{
  "id": "msg_abc123",
  "to": "+1234567890",
  "body": "Your OTP is 482910",
  "priority": "critical",
  "enqueued_at": 1705312200.123,
  "ttl_expires_at": 1705312500.123
}

// DLQ: Separate Redis LIST for expired/failed messages
RPUSH sms:dlq {expired_message_json}

// Monitoring keys
GET sms:queue:regular:length    // Current queue depth
GET sms:metrics:drain_rate      // Actual drain rate (should be ~100/sec)

Architecture · Leaky Bucket with Queue

Messages enter a queue (bucket) and drain at a fixed, constant rate regardless of input pattern

Outbound Rate Limiting · Leaky Bucket with Priority Queue INBOUND SMS REQUESTS (Variable Rate) Burst: 500 msg/sec Marketing campaign Normal: 50 msg/sec Steady transactional Critical: OTP/2FA Must not be delayed Spike: 2000 msg/sec Flash sale alerts LEAKY BUCKET (Queue) Priority Lane: OTP/2FA (reserved 20% drain capacity) Regular Queue (FIFO) · Capacity: 10,000 messages TTL per message: 5 min | Expired → DLQ Queue depth: 5,800 / 10,000 (58%) Capacity: 10K msgs If full: reject + 503 Overflow → Backpressure to upstream callers DRAIN FIXED OUTPUT: 100 msg/sec Timer: dequeue 1 message every 10ms Perfectly smooth, constant rate · no burst ever CARRIER (Downstream) Hard limit: 100 msg/sec Exceeding → silently dropped (no retry) ✗ Why NOT Token Bucket? Token bucket allows bursts up to capacity Carrier drops anything > 100/sec → Messages permanently lost
DecisionChoiceWhy
AlgorithmLeaky bucket (FIFO queue + fixed drain rate)Guarantees smooth, constant output. No burst ever reaches downstream. The textbook use case for leaky bucket.
QueueBounded FIFO queue (capacity: 10,000 messages)Absorbs up to ~100s of burst. When full, reject with backpressure signal (HTTP 503 to upstream callers).
DrainTimer-based: dequeue 1 message every 10ms (= 100/sec)Tick-based drain ensures perfectly uniform output regardless of input pattern.
OverflowWhen queue full: reject + DLQ for retry, or backpressure to upstreamNever drop silently. Upstream can retry or user sees delayed delivery.
PriorityPriority queue variant: OTP/2FA messages skip the regular queueCritical messages (login OTP) must not be delayed by marketing bulk sends.
MonitoringQueue depth + drain rate + overflow rate alertsIf queue stays > 80% for 5 minutes, scale capacity or shed low-priority load.
Leaky bucket explained: Think of a bucket with a hole at the bottom. Water (requests) pours in at variable rates. It leaks out at a fixed, constant rate. If the bucket overflows (queue full), water is lost (messages rejected). The output is always smooth.
Why NOT token bucket: Token bucket allows bursts up to bucket capacity. If you have 100 tokens saved up, you can send 100 messages instantly. The carrier would drop most of them. Leaky bucket guarantees no burst ever — exactly what a fixed-rate downstream requires.
Anti-patterns: Token bucket · allows bursts (fatal for fixed-rate downstream). Sliding window · allows micro-bursts within the window. No queue · rejects all overflow immediately (poor UX for legitimate traffic). Unbounded queue · memory exhaustion during sustained overload.
Real-world: Twilio · per-carrier rate limiting with queue. AWS SES · sending rate per second (leaky bucket). Stripe · outbound webhook delivery rate. SendGrid · per-domain throttling to avoid blacklisting.

Resilience & Edge Cases

FailureImpactRecovery
Sustained overload (> 100 msg/sec for hours)Queue fills up, delivery delays grow unboundedBackpressure to upstream. Shed low-priority messages. Alert on queue depth > 80%. Consider additional carrier routes.
Carrier outageDrain rate drops to 0, queue fillsPause drain timer. Switch to backup carrier. Queue persists messages for retry when carrier recovers.
Message expires in queueOTP delivered after timeout (useless)TTL per message. Expired messages moved to DLQ. Application regenerates OTP if needed.
Priority inversionBulk marketing messages delay OTPSeparate priority lanes: critical (OTP) dequeues first. Critical lane gets 20% of drain capacity reserved.
Multiple carriers with different ratesRouting complexityOne leaky bucket per carrier. Router assigns messages to carrier with shortest queue.

Interview Cheat Sheet

Key points for outbound rate limiting

1. Leaky bucket · fixed output rate, no bursts ever reach downstream (this is THE algorithm)
2. Bounded queue as buffer · absorb inbound spikes, reject overflow with backpressure
3. Timer-based drain · dequeue at fixed interval (10ms = 100/sec) regardless of input
4. Priority lanes · critical messages (OTP) bypass regular queue
5. TTL on queued messages · expired messages go to DLQ, not delivered stale
6. Say "leaky bucket" immediately · the keyword "must not burst" is the dead giveaway