Rate Limiting and Traffic Shaping

Felipe RodriguesFelipe Rodrigues
14 min read

It was the best of times, it was the worst of times. The marketing team had just launched a brilliant campaign for our new "AI-powered project summarizer," and the sign-ups were pouring in. Slack channels exploded with celebratory GIFs. We were watching the real-time analytics dashboard, and the numbers were climbing faster than we'd ever seen. Then, at 9:05 AM, the dashboard froze. A moment later, PagerDuty screamed. The entire platform was down.

The post-mortem was a familiar story of success-induced failure. A thundering herd of enthusiastic new users, all hitting the "Summarize My Project" button at once, had annihilated our backend. The service, which called a series of other internal APIs and crunched a lot of data, was expensive. The database connection pool was exhausted, CPU on the service instances was pegged at 100%, and a cascade failure took down adjacent, unrelated services.

The "quick fix" was just as predictable. A senior engineer on the team, under pressure to get the system stable, pushed a commit within the hour. It was a simple, in-memory counter inside the summarizer service: if user_requests_in_last_60_seconds > 20: return HTTP 429 Too Many Requests. The immediate fire was out. Management was happy. A small victory was declared.

But it wasn't a victory. It was the beginning of a long, slow defeat. That simple fix was a seed of technical debt that would sprout into a jungle of complexity. And this leads me to the core, slightly controversial thesis of our discussion today: Our industry's default approach to rate limiting, a stateless counter at the application layer, is a dangerous architectural fallacy. It's a security blanket that’s too small and full of holes. True system resilience isn’t about blocking requests; it’s about intelligently shaping traffic to match your system’s real-time capacity to perform work.

We’ve been trained to see rate limiting as a bouncer at the club door. My argument is that we need to start seeing it as an air traffic control system for our entire architecture.

Unpacking the Hidden Complexity

That "simple" in-memory counter felt like a pragmatic win. It was fast, easy to implement, and required no new infrastructure. But what were the second-order effects? What hidden complexities did we invite into our system?

First, the solution didn’t actually work reliably. Our summarizer service ran on a dozen container instances behind a load balancer. The in-memory counter was, of course, local to each instance. A savvy user (or a simple script) could easily bypass the limit by round-robining requests across different IP addresses of our load balancer, hitting a different instance with a fresh, empty counter each time. We weren't rate limiting the user; we were rate limiting a user's sticky session to a single container.

%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#fff3e0", "primaryBorderColor": "#e65100", "lineColor": "#424242"}}}%%
flowchart TD
    subgraph User
        U[Client Application]
    end

    subgraph Infrastructure
        LB[Load Balancer]
    end

    subgraph Backend Services
        S1[Service Instance 1 <br> In-Memory Counter A]
        S2[Service Instance 2 <br> In-Memory Counter B]
        S3[Service Instance 3 <br> In-Memory Counter C]
        DB[(Database)]
    end

    U -- Request 1 --> LB
    LB -- Route to Instance 1 --> S1
    S1 -- Update Counter A --> DB

    U -- Request 2 --> LB
    LB -- Route to Instance 2 --> S2
    S2 -- Update Counter B --> DB

    U -- Request 3 --> LB
    LB -- Route to Instance 3 --> S3
    S3 -- Update Counter C --> DB

    classDef services fill:#fff3e0,stroke:#e65100,stroke-width:2px;
    class S1,S2,S3 services;

This diagram illustrates the fundamental flaw of the naive, decentralized approach. The user's requests are distributed by the load balancer across multiple service instances. Each instance maintains its own separate, uncoordinated counter. A user can easily exceed the intended global limit because their requests are spread out, preventing any single instance's counter from reaching the threshold. The system lacks a single source of truth for the user's request rate.

Second, and far more insidiously, we were protecting the wrong thing. The goal wasn't to prevent a user from sending 21 requests. The goal was to prevent the database from tipping over. The rate limit was a proxy for a much more complex problem: resource contention. It treated a cheap GET /status check with the same weight as the enormously expensive POST /summarize call. This is a critical failure of understanding. We aren't managing requests; we are managing capacity.

This leads to the analogy I prefer. The naive rate limiter is a bouncer at a nightclub. He checks IDs and counts heads. If the club is "full" (the request count is exceeded), he tells you to wait. It's simple, but it creates a long, angry line outside and has no idea what’s happening inside. Is the bar overwhelmed while the dance floor is empty? The bouncer doesn't know or care.

A sophisticated traffic shaping system is like a modern air traffic control center. It doesn't just count planes. It knows the capacity of each runway, the weather conditions, the type of each aircraft (an Airbus A380 needs more resources than a Cessna), and the congestion in the taxiways. It dynamically sequences, prioritizes, and routes planes to maximize the throughput and safety of the entire airport system. It might ask a plane to circle (queue), divert it to a less busy runway (route to a different resource), or tell it to slow down (apply backpressure). The goal is to keep the entire system flowing, not just to guard a single gate.

To build such a system, we first need to understand the algorithms at our disposal. Choosing the right one is the foundation of an effective strategy.

AlgorithmHow It WorksKey ProKey ConBest For
Fixed Window CounterCounts requests in a static time window (e.g., 100 requests per minute). Resets on the minute.Very simple to implement. Low memory overhead.Allows double the rate at window edges (e.g., 100 requests at 00:59 and 100 at 01:00).Basic protection against unsophisticated abuse. Not for critical systems.
Sliding Window LogStores a timestamp for every request. Counts requests in the last N seconds.Perfectly accurate. No edge-of-window burst issue.Very high memory and storage cost as it stores all timestamps. Can be slow.Scenarios where perfect accuracy is non-negotiable and traffic volume is low.
Sliding Window CounterA hybrid approach. Divides the window into smaller buckets. Approximates the rate.Good balance of accuracy and performance. Mitigates the "edge burst" problem.More complex to implement than a fixed window. Still an approximation.General purpose API rate limiting. A very common and solid choice.
Token BucketA bucket is filled with tokens at a steady rate. A request consumes one or more tokens.Extremely flexible. Allows for controlled bursts of traffic (up to bucket size). Maps well to resource "costs".Slightly more complex state to manage (tokens and last updated time).APIs with variable request costs and where allowing short bursts is desirable.
Leaky BucketRequests are added to a queue (the bucket). The bucket "leaks" (is processed) at a constant rate.Smooths out traffic into a steady stream. Predictable egress rate.Bursts of traffic can lead to queue overflow and dropped requests. Can increase latency.Ingress to systems that require a very steady rate of processing, like video streaming or data ingestion pipelines.

The "quick fix" used a Fixed Window Counter, the crudest of them all. A mature architecture almost always evolves towards a Token Bucket or a sophisticated Sliding Window Counter, as these models allow us to move from simply counting requests to managing capacity.

The Pragmatic Solution: A Principled Blueprint

So, how do we move from the flawed bouncer model to the sophisticated air traffic control model? It's not about buying a fancy new tool. It's about a shift in architectural principles.

Principle 1: Centralize the Decision, Distribute the Enforcement. The state of the rate limit (the counters, the tokens) must live in a centralized, low-latency data store. This is the single source of truth. Redis is the canonical choice here for its speed and data structures like INCR and support for Lua scripting for atomic operations. The enforcement of the limit, however, should happen as far out to the edge as possible: in your API Gateway, your service mesh sidecar (like Envoy or Linkerd), or even a custom middleware in your web framework.

This avoids the problem of uncoordinated, in-memory counters and ensures a consistent, global view of a user's or service's consumption.

%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#e3f2fd", "primaryBorderColor": "#1565c0", "lineColor": "#333", "secondaryColor": "#fce4ec", "secondaryBorderColor": "#ad1457"}}}%%
flowchart TD
    subgraph EdgeLayer
        U[User] --> GW[API Gateway]
    end

    subgraph ControlPlane
        RLS[Rate Limiting Service]
        RDB[(Central Redis Store)]
        RLS -- Manages State --> RDB
    end

    subgraph ApplicationLayer
        S1[Service A]
        S2[Service B]
    end

    GW -- 1 - Request In --> GW
    GW -- 2 - Check Limit --> RLS
    RLS -- 3 - Read/Update Counter --> RDB
    RDB -- 4 - Current Count --> RLS
    RLS -- 5 - Decision Allow or Deny --> GW

    subgraph AllowedPath
        direction LR
        GW -- 6a. Allow --> S1
        S1 --> S2
    end

    subgraph DeniedPath
        direction LR
        GW -- 6b. Deny with 429 --> U
    end

    classDef edge fill:#e3f2fd,stroke:#1565c0,stroke-width:2px;
    classDef control fill:#fce4ec,stroke:#ad1457,stroke-width:2px;
    class GW,U edge;
    class RLS,RDB control;

This diagram shows a robust, centralized rate limiting architecture. A user request first hits the API Gateway, which acts as the enforcement point. Before routing the request to the backend, the Gateway makes a high-speed call to a dedicated Rate Limiting Service. This service, backed by a central data store like Redis, holds the state for all rate limits. It makes the decision to allow or deny the request based on global rules and returns that decision to the Gateway, which then either forwards the request or rejects it with an HTTP 429 status. This decouples the limiting logic from the application code and provides a single point of control.

Principle 2: Measure What Matters (Cost-Based Limiting). Stop counting requests. Start assigning a "cost" or "weight" to each API endpoint. A simple GET /users/{id} might have a cost of 1. A GET /users/{id}/posts might have a cost of 5. The expensive POST /summarize from our story might have a cost of 50.

Now, instead of giving a user "100 requests per minute," you give them "500 capacity units per minute." This is a monumental shift. It aligns your control mechanism with your actual system constraints. The Token Bucket algorithm is a natural fit for this model. A request for an endpoint with a cost of 10 consumes 10 tokens from the bucket. This automatically allows for many cheap requests but throttles expensive ones, protecting the system where it's most vulnerable.

Principle 3: Evolve from Rate Limiting to Traffic Shaping. A hard rejection (HTTP 429) is a crude, often user-hostile response. What if the system isn't at 100% capacity, but just approaching a threshold? This is where traffic shaping comes in. Instead of dropping a request, you can temporarily queue it.

The Leaky Bucket algorithm is the classic model for this. Requests enter a queue, and a processor pulls from that queue at a fixed, sustainable rate. This smooths out traffic bursts and turns a spiky, unpredictable workload into a smooth, predictable one for your backend services. This is especially powerful for asynchronous jobs. A user submits a request, gets an HTTP 202 Accepted response with a job ID, and the work is placed into a message queue that's processed at a rate your system can handle.

sequenceDiagram
    actor Client
    participant Gateway as API Gateway
    participant Limiter as Rate Limiter
    participant Queue as Message Queue
    participant Worker as Backend Worker

    Client->>Gateway: POST /expensive-job
    Gateway->>Limiter: Check Rate and Capacity
    alt Burst limit exceeded but queue has capacity
        Limiter-->>Gateway: Decision Shape via Queue
        Gateway->>Queue: Enqueue Job
        Gateway-->>Client: 202 Accepted (Job ID)
        loop Process at steady rate
            Worker->>Queue: Dequeue Job
            Worker->>Worker: Process Job
        end
    else Rate is within limits
        Limiter-->>Gateway: Decision Allow
        Gateway->>Worker: Process Job Immediately
        Worker-->>Gateway: 200 OK
        Gateway-->>Client: 200 OK
    else Hard limit exceeded
        Limiter-->>Gateway: Decision Deny
        Gateway-->>Client: 429 Too Many Requests
    end

This sequence diagram demonstrates the concept of traffic shaping. When the API Gateway receives a request, it consults the Rate Limiter. Instead of a simple "allow/deny," the limiter can make a more nuanced decision. If the user is over their burst limit but the system has capacity, the limiter can instruct the Gateway to queue the job. The client receives an immediate 202 Accepted response, providing a good user experience, while the backend worker processes jobs from the queue at a sustainable, smooth pace. This contrasts with the immediate processing of an in-limit request or the hard rejection of a request that exceeds all thresholds.

Traps the Hype Cycle Sets for You

As with any architectural pattern, the landscape is littered with buzzwords and silver bullets. Here are a few common traps to avoid.

  1. The "Just Use a Service Mesh" Trap. Tools like Istio and Linkerd are phenomenal. They provide powerful, distributed enforcement points (the Envoy sidecar is a perfect place to enforce a limit). But they are the how, not the what or the why. A service mesh gives you the plumbing, but it doesn't tell you which services to limit, by how much, or based on what criteria. You still need to do the hard work of defining your capacity, costs, and strategy. Dropping a mesh into your stack with default rate limits is like getting a race car and never taking it out of first gear.

  2. The "Global, Low-Latency Perfection" Trap. Engineers love to solve for Google's or Netflix's scale. We read about their globally distributed, paxos-based rate limiting systems and think we need one for our startup. You probably don't. For 95% of use cases, a single, vertically scaled Redis instance in your primary region provides more than enough performance and is orders of magnitude simpler to operate. Start there. When you can demonstrably prove that the latency from your edge to your central Redis is the bottleneck, then you can explore more complex multi-region replication strategies. Don't prematurely optimize for a problem you don't have.

  3. The "We'll Build It Ourselves" Trap. Building a robust, high-performance, and correct distributed rate limiter is hard. It's a product in itself. There are subtle race conditions, clock skew problems, and performance challenges. Companies like Stripe and GitHub have entire teams dedicated to this. Unless rate limiting is your core business competency, you should strongly favor using a well-maintained open-source solution or a managed service. Your engineering time is almost certainly better spent on your actual product.

Architecting for the Future

We've journeyed from a naive, broken counter to a principled, capacity-aware architecture. The fundamental shift is one of mindset. Stop thinking about rate limiting as a defensive gate. Start thinking about it as a core feature of your platform's reliability and scalability. The goal is not to say "no" to your users. The goal is to maximize the number of "yes" responses your system can give safely and sustainably. It's about gracefully handling success, not just warding off abuse.

So, what's your first move?

Your First Move on Monday Morning:

  1. Find Your True Bottleneck. Forget request counts. Go look at your observability platform. Is your primary database CPU the first thing to die during a traffic spike? Is it I/O on a shared file system? Is it a third-party API that you call? Identify the actual resource that is most constrained. That is what you must protect.

  2. Define a Cost Model. You don't need a perfect, scientifically-derived number. Just start. Pick your top 5 most-used and top 5 most-expensive API endpoints. Give the cheapest one a cost of 1. Give the most expensive one a cost of 100. Assign relative costs to the others in between. This simple act will force you to think about your system in terms of capacity instead of requests.

  3. Measure, Don't Guess. Instrument your code to log the "cost" of every request. Ship these logs to your observability tool. Build a dashboard that shows total cost per minute for the whole system, and cost per user per minute. For the first week, don't even enforce a limit. Just watch. You cannot set an intelligent limit until you understand your baseline.

This journey from reactive blocking to proactive shaping is the mark of a mature engineering organization. It's a move from fighting fires to designing a fire-resistant system.

And this leads me to a final, forward-looking question for you to ponder: As our systems become increasingly event-driven and asynchronous, how do we evolve our thinking from per-user or per-service limits to a more holistic concept of workload quotas that protect the health of an entire business domain?


TL;DR

  • The Problem: Simple, in-memory rate limiters in application code are a flawed pattern. They are inaccurate in distributed systems, protect the wrong resources, and treat all requests as equal.

  • The Core Idea: Shift from "rate limiting" (blocking requests) to "traffic shaping" (managing system capacity). Think like an air traffic controller, not a nightclub bouncer.

  • Key Principles:

    1. Centralize the Decision, Distribute the Enforcement: Use a central store like Redis for limit state but enforce the limit at the edge (API Gateway, service mesh).

    2. Measure What Matters: Don't count requests; assign a "cost" to each API endpoint based on the resources it consumes. Limit based on total cost.

    3. Shape, Don't Just Drop: Use queues and techniques like the Leaky Bucket algorithm to smooth traffic bursts instead of just returning 429 Too Many Requests.

  • Algorithms Compared: Fixed Window is simple but flawed. Sliding Window is more accurate. Token Bucket is excellent for cost-based limiting and allowing bursts. Leaky Bucket is ideal for smoothing traffic.

  • Common Traps: A service mesh is a tool, not a strategy. Don't over-engineer for global scale prematurely. Favor existing solutions over building your own.

  • First Actionable Steps: Identify your true system bottleneck, create a simple cost model for your APIs, and measure your traffic against that model before you enforce any limits.

0
Subscribe to my newsletter

Read articles from Felipe Rodrigues directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Felipe Rodrigues
Felipe Rodrigues