Rate Limiting, Throttling, and Backpressure: The Patterns That Keep Systems Alive
Rate limits, throttling, and backpressure are the three tools every distributed system needs to survive traffic spikes, misbehaving clients, and cascading failures. This post covers token bucket, sliding window, leaky bucket, queue-based backpressure, and when to use each — with real code, production gotchas, and design tradeoffs.
# Rate Limiting, Throttling, and Backpressure: The Patterns That Keep Systems Alive
A single misconfigured client brought down a production API I was running in 2022. Not a DDoS. Not a traffic spike. A cron job that lost its state file and replayed six months of data in thirty seconds, sending 80,000 requests per second at a service designed for 500.
We had rate limits. We had autoscaling. We had circuit breakers. None of them saved us, because the rate limit was a leaky bucket at the API gateway, and the gateway itself collapsed under the connection flood before it could enforce anything. The autoscaler kicked in, but the new instances were immediately overwhelmed by the same flood. The circuit breaker never tripped because the downstream service was trying to respond — it was just too slow.
This is the difference between knowing about rate limiting and understanding the system-wide dynamics of load shedding. Rate limiting, throttling, and backpressure are related but distinct patterns, and each one handles a different failure mode. If you only have one, you're not resilient — you're just slightly harder to break.
This post covers the three patterns, their implementations, and the traps I've fallen into with each one.
The Three Mechanisms, Defined
Let's get the terminology straight before diving into implementation, because teams confuse these constantly:
| Pattern | Who it protects | Direction | What it does |
|---|---|---|---|
| Rate limiting | The server/the system | Inbound | Rejects requests exceeding a threshold |
| Throttling | The downstream dependency | Outbound | Slows down outgoing calls to match capacity |
| Backpressure | The entire pipeline | Both (feedback loop) | Signals upstream to slow down via circuit-level feedback |
You need all three. Here's why and how.
Pattern 1: Rate Limiting — The API Gateway Layer
Rate limiting is the most visible of the three. Every public API has it — Stripe, GitHub, Google Maps — and every developer has hit a 429 Too Many Requests response. But the algorithm you choose determines how fair, predictable, and attack-resistant your rate limiter is.
Token Bucket: The Workhorse
The token bucket is the most widely used rate-limiting algorithm because it's simple and allows bursts. The idea: you have a bucket that fills with tokens at a steady rate (say, 10 tokens/second). Each request consumes one token. If the bucket is empty, the request is rejected. But the bucket has a maximum capacity, so unused tokens accumulate up to that limit — allowing short bursts.
// Token Bucket implementation in Go
type TokenBucket struct {
mu sync.Mutex
capacity int64
tokens int64
refillRate float64 // tokens per second
lastRefill time.Time
}
func NewTokenBucket(capacity int64, refillRate float64) *TokenBucket {
return &TokenBucket{
capacity: capacity,
tokens: capacity,
refillRate: refillRate,
lastRefill: time.Now(),
}
}
func (tb *TokenBucket) refill() {
elapsed := time.Since(tb.lastRefill).Seconds()
newTokens := int64(elapsed * tb.refillRate)
if newTokens > 0 {
tb.tokens = min(tb.capacity, tb.tokens+newTokens)
tb.lastRefill = time.Now()
}
}
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
tb.refill()
if tb.tokens > 0 {
tb.tokens--
return true
}
return false
}
Where token bucket shines: Variable traffic patterns. A social media API that gets 100 req/s average but 500 req/s during a viral post. The burst capacity (bucket size) absorbs the spike without rejecting legitimate users.
Where it fails: Distributed state. If you have ten API gateway instances, each with its own in-memory token bucket, a client can send 10× the limit by round-robining across instances. You need a shared store.
// Redis-backed token bucket for distributed rate limiting
type RedisTokenBucket struct {
client *redis.Client
key string
capacity int64
refillRate float64
}
func (r *RedisTokenBucket) Allow(ctx context.Context) (bool, error) {
// Lua script ensures atomicity — critical for correctness
script := redis.NewScript(`
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local bucket = redis.call("HGETALL", key)
local lastRefill = 0
local tokens = capacity
if #bucket > 0 then
tokens = tonumber(bucket[2] or capacity)
lastRefill = tonumber(bucket[4] or 0)
end
local elapsed = now - lastRefill
local newTokens = math.floor(elapsed * refillRate)
tokens = math.min(capacity, tokens + newTokens)
if tokens >= 1 then
redis.call("HSET", key, "tokens", tokens - 1, "lastRefill", now)
redis.call("EXPIRE", key, math.ceil(capacity / refillRate) + 1)
return 1
end
redis.call("HSET", key, "tokens", tokens, "lastRefill", now)
redis.call("EXPIRE", key, math.ceil(capacity / refillRate) + 1)
return 0
`)
result, err := script.Run(ctx, r.client, []string{r.key},
r.capacity, r.refillRate, time.Now().Unix()).Int()
if err != nil {
return false, err
}
return result == 1, nil
}
``$
The Lua script is non-negotiable — without it, a race condition between `GET` and `SET` means two concurrent requests can both pass when only one should. Redis's `EVAL` guarantees atomic execution.
### Sliding Window Log: The Fair One
The sliding window approach stores a timestamp for each request within the current window and counts them. It's more memory-intensive than token bucket, but it's perfectly accurate in preventing bursts within the window.typescript
// Sliding window rate limiter in TypeScript (in-memory)
class SlidingWindowRateLimiter {
private windows: Map
constructor(
private windowMs: number,
private maxRequests: number
) {}
allow(key: string): boolean {
const now = Date.now();
let timestamps = this.windows.get(key) || [];
// Remove timestamps outside the window
timestamps = timestamps.filter(t => now - t < this.windowMs);
if (timestamps.length >= this.maxRequests) {
this.windows.set(key, timestamps);
return false;
}
timestamps.push(now);
this.windows.set(key, timestamps);
return true;
}
// Get time until next allowed request (for Retry-After header)
retryAfter(key: string): number {
const now = Date.now();
const timestamps = this.windows.get(key) || [];
if (timestamps.length < this.maxRequests) return 0;
const oldest = timestamps[timestamps.length - this.maxRequests];
return Math.max(0, this.windowMs - (now - oldest));
}
}
`$
// Redis sorted-set-based sliding window
func (r *RedisSlidingWindow) Allow(ctx context.Context, userId string) (bool, time.Duration, error) {
now := time.Now().UnixMilli()
windowStart := now - r.windowMs
key := fmt.Sprintf("ratelimit:%s", userId)
script := redis.NewScript(`
local key = KEYS[1]
local now = tonumber(ARGV[1])
local windowStart = tonumber(ARGV[2])
local max = tonumber(ARGV[3])
-- Remove old entries
redis.call("ZREMRANGEBYSCORE", key, 0, windowStart)
-- Count current entries
local count = redis.call("ZCARD", key)
if count >= max then
-- Get the oldest entry to calculate retry-after
local oldest = redis.call("ZRANGE", key, 0, 0, "WITHSCORES")
local retryAfter = math.ceil((max - count + 1) * (now - tonumber(oldest[2])) / count)
return {0, retryAfter}
end
-- Add current request
redis.call("ZADD", key, now, now)
redis.call("EXPIRE", key, math.ceil(r.windowMs / 1000) + 1)
return {1, 0}
`)
result, err := script.Run(ctx, r.client, []string{key},
now, windowStart, r.maxRequests).Slice()
if err != nil {
return false, 0, err
}
allowed := result[0].(int64) == 1
retryAfterMs := result[1].(int64)
return allowed, time.Duration(retryAfterMs) * time.Millisecond, nil
}
``$
### Fixed Window: Don't Use It
Fixed window (count requests in 1-second buckets) has a well-known edge case: if you have a limit of 100 req/s and a client sends 100 requests at 999ms and another 100 at 1001ms, you let through 200 requests in 2ms — twice your intended capacity. This burst amplification makes fixed window unsuitable for anything where accuracy matters.
### Choosing Your Algorithm
| Algorithm | Burst handling | Memory | Distributed | Global accuracy |
|---|---|---|---|---|
| Token bucket | Excellent (configurable) | O(1) | Redis Lua script | Good |
| Sliding window log | Perfect | O(N) | Redis sorted set | Best |
| Sliding window counter | Good (approximate) | O(1) | Redis counter | Very good |
| Fixed window | Poor (burst amplification) | O(1) | Easy | Poor |
| Leaky bucket | None (smooths only) | O(1) | Redis counter | Good (no bursts) |
My rule of thumb: **use token bucket for most APIs** (configurable burst handling, low memory), and **sliding window log for billing-critical limits** where you need perfect counting.
---
## Pattern 2: Throttling — Protecting Your Downstreams
Throttling is what you do when you're the client, not the server. Your service needs to call a database, an external API, or a queue, and that dependency has its own capacity constraints. If you hit it harder than it can handle, you cause failures that cascade back to your users.
### The Concurrency Limiter
The simplest form of throttling is limiting concurrent requests. This prevents a sudden flood of work from overwhelming a downstream service.go
// Concurrency-limited HTTP client in Go
type ThrottledHTTPClient struct {
client *http.Client
sem chan struct{}
timeout time.Duration
}
func NewThrottledHTTPClient(maxConcurrent int, timeout time.Duration) *ThrottledHTTPClient {
return &ThrottledHTTPClient{
client: &http.Client{Timeout: timeout},
sem: make(chan struct{}, maxConcurrent),
timeout: timeout,
}
}
func (c ThrottledHTTPClient) Do(req http.Request) (*http.Response, error) {
// Acquire a semaphore slot — blocks if at capacity
c.sem <- struct{}{}
defer func() { <-c.sem }()
// Add a context timeout that respects throttling wait time
ctx, cancel := context.WithTimeout(req.Context(), c.timeout)
defer cancel()
return c.client.Do(req.WithContext(ctx))
}
$
The channel-as-semaphore pattern is idiomatic Go. The buffer size (maxConcurrent) is hard cap — if all slots are occupied, the caller blocks. This is intentional: it propagates backpressure to the caller, which is better than queueing indefinitely and timing out later.
Adaptive Throttling
Static throttling limits are brittle. Your limit of 50 concurrent database calls might work at 3PM but be too aggressive at 3AM when the database is being backed up. Adaptive throttling adjusts the concurrency limit based on real-time feedback from the downstream.
Google's SRE book describes client-side throttling with request cost accounting. The idea: track the ratio of successful requests to total requests. If success rate drops below a threshold, reduce the concurrency limit. If it recovers, increase it.
// Adaptive throttling — inspired by Google SRE techniques
type AdaptiveThrottle struct {
mu sync.RWMutex
maxConcurrency int64
minConcurrency int64
successCount int64
totalCount int64
lastAdjustment time.Time
}
func (at *AdaptiveThrottle) ShouldAccept() bool {
at.mu.RLock()
max := at.maxConcurrency
total := at.totalCount
success := at.successCount
at.mu.RUnlock()
// If we're below max concurrency, always accept
// (concurrency tracking happens elsewhere)
if max <= 0 {
return true
}
// Google SRE formula: accept if requests * (success/total) < max
// Simplified: reject when success rate drops below 50%
if total > 100 { // warm-up period
successRate := float64(success) / float64(total)
if successRate < 0.5 {
at.adjust(-1)
return false
}
if successRate > 0.9 {
at.adjust(1)
}
}
return true
}
func (at *AdaptiveThrottle) Record(succeeded bool) {
at.mu.Lock()
defer at.mu.Unlock()
at.totalCount++
if succeeded {
at.successCount++
}
// Decay old counts to avoid stale data
if at.totalCount > 10000 {
at.totalCount /= 2
at.successCount /= 2
}
}
func (at *AdaptiveThrottle) adjust(delta int64) {
if time.Since(at.lastAdjustment) < 5*time.Second {
return // don't adjust more than once per 5 seconds
}
at.lastAdjustment = time.Now()
newMax := at.maxConcurrency + delta
if newMax >= at.minConcurrency && newMax <= 1000 {
at.maxConcurrency = newMax
}
}
``$
**Why adaptive matters:** In the incident I described at the start, a static rate limit of 500 req/s on the API gateway was useless because the gateway itself was saturated at 80,000 connections. An adaptive throttle on *every outgoing call* to the database would have started rejecting requests as soon as database latency spiked, before the gateway-level rate limiter ever saw a problem.
### The Queue: When You Can't Drop Requests
Sometimes you can't return 429. Payment processing, order fulfillment, audit logging — these need to go through eventually, even if slowly. That's where queues and throttled producers come in.typescript
// Token-bucket-based throttler for queue producers in TypeScript
class ThrottledQueueProducer {
private bucket: TokenBucket;
private queue: Array<{ item: unknown; retries: number }> = [];
private processing = false;
private maxRetries = 3;
constructor(
private producer: (item: unknown) => Promise<void>,
ratePerSecond: number,
burstSize: number
) {
this.bucket = new TokenBucket(burstSize, ratePerSecond);
}
async enqueue(item: unknown): Promise<void> {
this.queue.push({ item, retries: 0 });
if (!this.processing) {
this.process();
}
}
private async process(): Promise<void> {
this.processing = true;
while (this.queue.length > 0) {
if (!this.bucket.allow()) {
// No tokens available — wait for refill
await this.sleep(1000 / this.bucket.refillRate);
continue;
}
const job = this.queue.shift()!;
try {
await this.producer(job.item);
} catch (err) {
if (job.retries < this.maxRetries) {
this.queue.unshift({ ...job, retries: job.retries + 1 });
console.warn(Retry ${job.retries + 1}/${this.maxRetries});
} else {
console.error(Failed after ${this.maxRetries} retries:, err);
}
}
}
this.processing = false;
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
$
Pattern 3: Backpressure — The System-Level Feedback Loop
Backpressure is the most sophisticated of the three patterns and the most misunderstood. It's not rate limiting applied to yourself. It's a protocol between components where the downstream tells the upstream "I'm at capacity" and the upstream either slows down or stops sending.
TCP Backpressure: The Original
TCP congestion control is the canonical example of backpressure. When a receiver's buffer is full, it stops ACKing packets. The sender sees missing ACKs, assumes congestion, and reduces its send window. The receiver never explicitly says "slow down" — the feedback is implicit in the missing ACKs.
This is the model for application-level backpressure.
Reactive Streams / RSocket
The most formalised application-level backpressure model comes from Reactive Streams (adopted by Project Reactor, RxJava, Akka Streams). The core concept: the consumer tells the producer how many items it can handle, and the producer never exceeds that amount.
// Minimal backpressure protocol in TypeScript
interface Subscriber<T> {
onNext(item: T): void;
onError(err: Error): void;
onComplete(): void;
}
interface Subscription {
request(n: number): void; // THE KEY METHOD
cancel(): void;
}
interface Publisher<T> {
subscribe(subscriber: Subscriber<T>): Subscription;
}
// Example: a database query publisher with backpressure
class DatabaseQueryPublisher implements Publisher<Row> {
constructor(
private db: Database,
private query: string,
private pageSize: number = 100
) {}
subscribe(subscriber: Subscriber<Row>): Subscription {
let cancelled = false;
let demand = 0;
let offset = 0;
let inFlight = false;
const subscription: Subscription = {
request: async (n: number) => {
demand += n;
if (inFlight || cancelled) return;
inFlight = true;
while (demand > 0 && !cancelled) {
const batch = await this.db.query(
`${this.query} LIMIT ${this.pageSize} OFFSET ${offset}`
);
for (const row of batch) {
if (demand <= 0 || cancelled) break;
subscriber.onNext(row);
demand--;
offset++;
}
// If the batch was smaller than pageSize, we're done
if (batch.length < this.pageSize) {
subscriber.onComplete();
return;
}
}
inFlight = false;
},
cancel: () => { cancelled = true; }
};
return subscription;
}
}
// Usage: the subscriber controls the pace
const publisher = new DatabaseQueryPublisher(db, "SELECT * FROM orders");
const subscription = publisher.subscribe({
onNext: (row) => {
process(row);
// Request the next item only after processing the current one
subscription.request(1);
},
onError: (err) => console.error(err),
onComplete: () => console.log("Done")
});
// Start with the first item
subscription.request(1);
``$
This is fundamentally different from a simple "fetch next page" loop because the *producer* respects the *consumer's* capacity. If the consumer processes slowly, the producer pauses. If the consumer processes fast, the producer sends more. The consumer drives the rate.
### gRPC Backpressure via Flow Control
gRPC has built-in HTTP/2 flow control, but it works at the stream level, not the application level. If your server is processing requests slowly, the gRPC library will buffer them until the stream's window is exhausted. At that point, the server stops reading from the socket, and the TCP backpressure propagates to the client.
But this implicit backpressure is coarse-grained. For fine-grained control, use gRPC's **server-side streaming** with a custom backpressure signal:protobuf
// gRPC service with explicit backpressure
service DataProcessor {
// Client sends items, server streams back pressure signals
rpc ProcessData(stream DataItem) returns (stream BackpressureSignal);
}
message DataItem {
bytes payload = 1;
}
message BackpressureSignal {
int32 max_inflight = 1; // How many items the server can handle now
int32 retry_after_ms = 2; // Suggest wait time
ProcessStatus status = 3; // READY, BUSY, CRITICAL
}
enum ProcessStatus {
READY = 0;
BUSY = 1;
CRITICAL = 2;
}
$
// Server-side: send backpressure signals based on internal queue depth
func (s *DataProcessorServer) ProcessData(stream pb.DataProcessor_ProcessDataServer) error {
const maxQueue = 100
queue := make(chan *pb.DataItem, maxQueue)
// Worker goroutine — processes items from the queue
go func() {
for item := range queue {
process(item) // This may be slow
}
}()
for {
// Send backpressure signal before receiving the next item
status := pb.ProcessStatus_READY
maxInflight := int32(maxQueue - len(queue))
switch {
case len(queue) > int(float64(maxQueue)*0.8):
status = pb.ProcessStatus_CRITICAL
maxInflight = 0
case len(queue) > int(float64(maxQueue)*0.5):
status = pb.ProcessStatus_BUSY
}
if err := stream.Send(&pb.BackpressureSignal{
MaxInflight: maxInflight,
RetryAfterMs: int32(len(queue) * 10), // estimate
Status: status,
}); err != nil {
return err
}
// Now receive the next item
item, err := stream.Recv()
if err == io.EOF {
close(queue)
return nil
}
if err != nil {
close(queue)
return err
}
queue <- item
}
}
``$
The client reads the backpressure signal before sending each batch. If `maxInflight` drops to zero, the client pauses. When it recovers, the client resumes.
### Channel-Based Backpressure in Go
Go channels are a natural fit for backpressure. A buffered channel is a bounded queue; when it's full, the sender blocks. That blocking *is* backpressure.go
// Channel-based backpressure pipeline
func DataPipeline(ctx context.Context, input <-chan RawData) <-chan ProcessedData {
const workers = 5
const buffer = 100
type work struct {
data RawData
ack chan struct{} // backpressure ack channel
}
workChan := make(chan work, buffer)
output := make(chan ProcessedData, buffer)
// Dispatcher: wraps each data item with an ack channel
go func() {
defer close(workChan)
for data := range input {
w := work{data: data, ack: make(chan struct{}, 1)}
select {
case workChan <- w:
// Block here until worker ACKs
// This IS the backpressure signal
select {
case <-w.ack:
// Worker confirmed receipt
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
// Workers: process items and ACK
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for w := range workChan {
result := expensiveTransform(w.data)
select {
case output <- result:
case <-ctx.Done():
return
}
close(w.ack) // Signal dispatcher we've processed this
}
}()
}
go func() {
wg.Wait()
close(output)
}()
return output
}
$
The key insight: the dispatcher blocks on sending to workChan only when the channel is full (buffer=100). But it also blocks on receiving the ack, which means the dispatcher can't send more than one item per worker at a time. This creates a pull-based system where the workers control the pace.
Putting It All Together: A Multi-Layer Defence
Here's what the incident from the intro should have looked like with all three patterns in place:
Client ──► API Gateway ──► Service A ──► Database
│ │
Rate limiter Throttler
(token bucket) (adaptive, 50 concurrent max)
│ │
Backpressure ◄── Backpressure ◄── Backpressure
(429 response) (delayed acks) (connection pool wait)
| Layer | Pattern | What it does |
|---|---|---|
| Gateway | Rate limiting | Rejects excess client requests with 429 |
| Service A | Throttling | Caps concurrent DB calls at 50 |
| Service A | Adaptive throttle | Reduces cap to 25 when DB latency spikes |
| Service A → DB | Backpressure | Connection pool blocks caller when saturated |
| Client | Backpressure | Receives 429, backs off and retries |
| Gateway → Service A | Backpressure | Downstream latency causes slow responses, which causes the gateway to slow its accept rate via its own adaptive throttle |
The failure I described originally would have played out differently: the gateway's token bucket would have rejected most of the 80,000 req/s (rate limiting). The 500 req/s that got through would have hit Service A's throttler, which would have queued DB calls at 50 qps (throttling). The database would have responded normally, Service A would have responded normally, and the rare 429s from the throttled queue being full would have triggered the client's exponential backoff (backpressure).
Production Gotchas (From Painful Experience)
Gotcha 1: Rate Limiting by IP in a NAT World
If you rate limit by IP behind a corporate NAT, you're throttling an entire office. More importantly, Kubernetes pods share node IPs for outgoing traffic via SNAT. Rate limiting a single caller by source IP in a Kubernetes cluster will throttle all pods on that node.
Fix: Use API keys, bearer tokens, or session IDs as the rate limit key, not source IP. Use IP-based limiting only at the edge (CDN/cloud load balancer) for basic DDoS protection.Gotcha 2: The Circuit Breaker Is Not Rate Limiting
A circuit breaker (Hystrix, Resilience4j, Polly) prevents calls after a failure threshold is reached. Rate limiting prevents calls before capacity is exceeded. They serve different purposes:
- Circuit breaker: "This downstream is broken, stop calling it entirely for a while."
- Rate limiter: "This client is sending too much, slow down."
If your database is down, the circuit breaker saves you from hammering it. If your database is fine but a client is overloaded, the rate limiter saves it.
Gotcha 3: Concurrency Limits vs Rate Limits
These are different:
- Concurrency limit: "No more than 50 requests in flight at once."
- Rate limit: "No more than 100 requests per second."
A burst of 200 requests in one second has a rate of 200 req/s but a concurrency of maybe 5 (each request completes in 25ms). A rate limit would catch this; a concurrency limit would not.
You need both. Use concurrency limits to protect downstream capacity (connections, threads, file handles). Use rate limits to protect upstream throughput (API billing, database write capacity).
Gotcha 4: Retry Storms
When you return 429, clients should respect the Retry-After header. But when they don't, or when 100 clients all retry at exactly the same time, you get a retry storm that amplifies the original load.
`go
// Jittered exponential backoff — always add randomness
func backoff(retries int) time.Duration {
base := time.Duration(math.Pow(2, float64(retries))) * time.Second
jitter := time.Duration(rand.Int63n(int64(base))) // up to 100% jitter
return base + jitter
}
`$
Full jitter (up to 100% of the base) prevents thundering herds better than capped jitter. The total window is wider, but the peak load is dramatically lower.
Gotcha 5: Rate Limiting ≠ Security
Rate limiting is a load-shedding mechanism, not a security control. A sophisticated attacker will:
- Distribute requests across many IPs (botnet)
- Use valid API keys stolen from different accounts
- Stay just under each rate limit layer
For actual DDoS protection, use cloud WAF, CDN-level DDoS scrubbing, and SYN flood protection. Rate limiting is a last line of defence for system stability, not thefirst.
The Audit Checklist
Use this when designing the load-shedding architecture for your next service:
- [ ] Does the API gateway rate-limit by user/API key (not IP)?
- [ ] Does each service throttle outbound calls to its dependencies?
- [ ] Are concurrency limits separate from rate limits?
- [ ] Is there a backpressure mechanism from DB → service → gateway?
- [ ] Do clients implement exponential backoff with jitter?
- [ ] Is the rate limiter stateless enough to deploy horizontally?
- [ ] Are rate limit headers sent (X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After)?
- [ ] Is the token bucket stored in Redis (or equivalent shared store)?
- [ ] Are there circuit breakers for downstream failures?
- [ ] Is the throttling adaptive (adjusts based on real-time success rates)?
If you answered "no" to more than two, your system will not survive a real traffic emergency.
An Honest Ending
No amount of rate limiting, throttling, and backpressure makes your system invincible. If the traffic is high enough, or the downstream failure is catastrophic enough, something will break. The goal is not to never break. The goal is to break gracefully — to fail one client at a time instead of the entire cluster, to degrade performance instead of falling over entirely, to give your team time to respond instead of waking them at 3AM to a dead service.
The service I lost in 2022? It recovered after we rolled back the client's state, cleared the connection backlog, and restarted the database. It took 45 minutes of 500s. With proper rate limiting, throttling, and backpressure, that 45 minutes would have been a handful of 429s and a slow but operational service.
Twenty lines of Redis Lua scripts, a semaphore channel, and a backpressure-aware data pipeline — that's all it would have taken.