Back to Blog
EngineeringJuly 16, 2026·16 min

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-limitingthrottlingbackpressuredistributed-systemsapi-designresiliencescalabilitygotypescript

# 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:

PatternWho it protectsDirectionWhat it does
Rate limitingThe server/the systemInboundRejects requests exceeding a threshold
ThrottlingThe downstream dependencyOutboundSlows down outgoing calls to match capacity
BackpressureThe entire pipelineBoth (feedback loop)Signals upstream to slow down via circuit-level feedback
Rate limiting says "you can send 100 req/s, no more." It's a contract. Throttling says "the database can handle 50 qps, so I'll queue my writes and send them at 50 qps max." It's a self-preservation mechanism. Backpressure says "I'm overwhelmed — slow down your input, and I'll tell you when I've caught up." It's a communication protocol, not a policy decision.

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 = new 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));

}

}

`$

The problem with in-memory sliding window: Memory grows with request volume. For a 1-hour window of 10,000 requests, you're storing 10,000 timestamps per user. Sorted sets in Redis solve this:
// 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));

}

}

$

The queue trap: unbounded queues in memory. If the producer is slower than the enqueue rate, the queue grows until OOM. Always set a max queue size and reject new items when it's full.

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)
LayerPatternWhat it does
GatewayRate limitingRejects excess client requests with 429
Service AThrottlingCaps concurrent DB calls at 50
Service AAdaptive throttleReduces cap to 25 when DB latency spikes
Service A → DBBackpressureConnection pool blocks caller when saturated
ClientBackpressureReceives 429, backs off and retries
Gateway → Service ABackpressureDownstream 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:

  1. Distribute requests across many IPs (botnet)
  2. Use valid API keys stolen from different accounts
  3. 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.

Got a project that needs illuminating?

We bring clarity to complex software challenges. Let's talk.

Get In Touch