Ridwan Alam

Writing

Distributed Rate Limiting

A counter in process memory stops meaning anything the moment you run a second replica: each instance enforces its own limit, so ten pods let through ten times the traffic you configured. What it costs to keep one shared count honest.

Sep 10, 2026
  • System Design
  • Distributed Systems
Part I / The Problem
01

Many Doors, One Guest List

Most of us know what rate limiting is: a service decides how many requests a client may make in a window of time and turns the rest away. On a single server that is a counter in memory and a few lines of code. Fewer people have had to think about distributed rate limiting, and it is the version that matters the moment a system runs on more than one machine. Amazon, Uber, Databricks and Cloudflare all enforce limits across fleets of servers, and each has built something quite different to do it.

A nightclub holds 200 people, and one bouncer with a clicker at the door keeps it that way. Now give the club ten doors, each with its own bouncer and its own clicker. Every bouncer stops at 200, and a thousand people get in before anyone notices.

Distributed rate limiting is the answer to that problem: one global limit, enforced consistently across many servers, containers or regions, rather than a separate limit per instance. It protects the same three things single-node limiting does, now at the scale of a fleet.

  • The building. Backends can be overwhelmed by traffic spread across instances. A client sending 100 requests a second to each of 10 servers is running at ten times a global limit of 100.
  • The other guests. In a multi-tenant system one noisy tenant should not degrade the service for everyone else, on any node.
  • The bar tab. Compute, bandwidth and third-party API calls cost money in proportion to total throughput, not per-instance throughput.
The central tension

Accuracy requires coordination, and coordination costs latency. Every company in this post has picked a different point on that line, and where each one landed says a lot about its scale.

02

Why Local Counters Fail

Here is the failure that catches every team once. You build a clean in-memory rate limiter, test it, ship it. Then you scale to ten servers behind a load balancer, and your limit of 100 requests per second quietly becomes 1,000, because each server is counting on its own.

Ten doors, ten clickers
The same limit, counted per server and counted once
INDEPENDENT COUNTERS client load balancer server 1 100 / 100 ✓ server 2 100 / 100 ✓ server 3 100 / 100 ✓ 300 req/s let through ONE SHARED COUNTER client load balancer server 1 server 2 server 3 Redis · one Lua script atomic check + decrement 100 req/s let through
On the left every server is individually correct and the system as a whole is three times over its limit. Nothing in any one server's state shows the problem.

The fix is shared state: every server checks the same counter. But shared is not enough on its own. The counter also has to be atomic. Suppose a limiter reads the count, decides, then writes the new value back as three separate operations, and a client has exactly one request left.

A
Server A reads the counter: 99.
B
Server B reads the counter: also 99.
A
99 is under 100, so A allows its request and writes 100.
B
99 is under 100, so B allows its request and writes 100. Two requests got the last slot, and the counter still says 100.
The overshoot leaves no trace

Nothing in the stored data shows that the limit was exceeded, and under real load it is not two requests racing, it is dozens. A shared counter that is read and written in separate steps is only a distributed version of the same bug.

03

Making the Check Atomic

The fix is to make the whole check-and-decrement one indivisible operation that runs where the data lives. This is why Redis plus Lua is the standard pairing. Redis runs a script to completion before it serves any other command, so no two servers can ever act on the same count.

token_bucket.lua
-- KEYS[1] bucket key   ARGV: rate (tokens/s), capacity, tokens requested
local rate      = tonumber(ARGV[1])
local capacity  = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])

-- The clock comes from Redis, not from the caller.
local t   = redis.call("TIME")
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)

local state  = redis.call("HMGET", KEYS[1], "tokens", "ts")
local tokens = tonumber(state[1]) or capacity
local ts     = tonumber(state[2]) or now

-- Refill for the time since the last request, never past capacity.
tokens = math.min(capacity, tokens + math.max(0, now - ts) / 1000 * rate)

local allowed = 0
if tokens >= requested then
  tokens  = tokens - requested
  allowed = 1
end

redis.call("HSET", KEYS[1], "tokens", tokens, "ts", now)
redis.call("PEXPIRE", KEYS[1], math.ceil(capacity / rate * 1000))
return allowed

The whole decision runs inside Redis and cannot be interrupted. The PEXPIRE lets a bucket that has refilled to full disappear, since it no longer holds any information.

Take the clock from the store

Many examples pass now in from the application server. With ten servers that is ten slightly different clocks, and a server running a few hundred milliseconds fast refills buckets early for every client it handles. Reading TIME inside the script gives the whole fleet one clock.

The caller is short. The interesting line is the error branch.

redis_token_bucket.go
// Take reports whether a request costing n tokens may proceed.
func (b *RedisTokenBucket) Take(ctx context.Context, key string, n int) bool {
    // Run sends EVALSHA and falls back to EVAL only when a node has not seen
    // the script yet, so the script body crosses the network once per node.
    allowed, err := b.script.Run(ctx, b.client, []string{key}, b.rate, b.capacity, n).Int()
    if err != nil {
        // Fail open: a limiter outage must not turn into an API outage.
        b.FailOpens.Add(1) // export it, alert on it
        return true
    }
    return allowed == 1
}

The Lua script and the full Go type are in this post's codes/ directory.

Fail open or fail closed

If Redis goes down, either everything gets through (fail open, and you risk overload) or nothing does (fail closed, and a limiter outage becomes a total outage). Most production limiters fail open and alert loudly. Whichever you choose, choose it deliberately rather than finding out which one your code does during an incident.

Part II / Patterns and Algorithms
04

Three Architectural Patterns

A Redis call on every request is the simplest correct design, and it is where most systems start. It is not where the largest ones stay. Real architectures fall into three broad patterns, sorted by how much coordination sits on the request path.

Pattern How it works Gains Costs Examples
Centralized One shared store holds every counter; each request checks it. Exact, simple to reason about A network hop per request, a single point of failure, a throughput ceiling Stripe, LaunchDarkly (Centralized), Lyft ratelimit
Hybrid Decisions are made locally; usage is reported and synchronised with a shared view periodically. Local latency, near-global accuracy More moving parts; brief overshoot between syncs Databricks, LaunchDarkly (Floodgate), Envoy local + global
Decentralized Each node decides alone, either on a policy pushed from a control plane or on a partition of traffic it fully owns. No external dependency on the hot path Approximate; enforcement lags reality Uber GRL, Cloudflare (per PoP), LaunchDarkly (Poisson)
The direction of travel

As traffic grows, systems move down this table. The per-request round trip is the first thing to go, and exactness is the price. The case studies in Part III are mostly accounts of when and why that move happened.

05

The Algorithms, Revisited for Distribution

The algorithms are the same ones used on a single node. Distribution changes where their state lives and what has to be atomic.

Token bucket: the arcade coin dispenser

A machine drops a coin into your tray at a steady rate, and the tray holds only so many. Every game costs a coin. Save up and you can play a burst of games; empty the tray and you wait for the next coin.

Each client has a bucket of tokens, refilled at a steady rate up to a capacity, and each request spends one. It is the most common algorithm in distributed systems because it takes two intuitive parameters and tolerates bursts. Distributed twist: the bucket lives in a shared store and the refill-and-spend must be atomic, which is exactly the Lua script in section 3.

Used by: Stripe, AWS API Gateway, Databricks.

Sliding window counter: the bouncer's estimate

The bouncer does not keep a log of every arrival time. They remember how many came in last hour and how many so far this hour, and estimate the rest.

Keep two counters per key: the previous fixed window and the current one. Weight the previous window by how much of it the sliding window still overlaps, and add the current count.

sliding_window.go
// Cloudflare's worked example, limit 50/min: 42 requests last minute,
// 18 so far this minute, 15 seconds in.
//   42 * (60-15)/60 + 18 = 49.5  -> under 50, allowed
elapsed := now.Sub(windowStart)
overlap := 1 - float64(elapsed)/float64(window)
estimate := float64(prevCount)*overlap + float64(currCount)
allowed := estimate < float64(limit)

Distributed twist: two integers per key is small enough to keep for every source address hitting every domain, where a log of per-request timestamps would not be. The estimate assumes traffic in the previous window was evenly spread, and in practice that holds up well: Cloudflare measured it on 400 million requests and found an average difference of 6% from the true rate.

Used by: Cloudflare.

Fixed window: the parking meter

Feed the meter and you get the hour, however you spend it. At the top of the hour it resets, whatever you did at 59 minutes.

One counter per key per clock window, with an expiry. The simplest option there is.

fixed_window.go
key := fmt.Sprintf("rl:%s:%d", clientID, time.Now().Unix()/60)

// One MULTI/EXEC: a crash between a bare INCR and EXPIRE would leave a
// counter that never expires, and a client limited forever.
pipe := rdb.TxPipeline()
count := pipe.Incr(ctx, key)
pipe.Expire(ctx, key, 2*time.Minute)
if _, err := pipe.Exec(ctx); err != nil {
    return true // fail open
}
return count.Val() <= limit
The boundary burst

A client can send the full limit in the last second of one window and the full limit again in the first second of the next: twice the limit in two seconds, and every request allowed. That is fine for generous limits and wrong for anything protecting a fragile backend.

Used by: Lyft's ratelimit service, GitHub's primary limits.

Leaky bucket: the funnel

Pour as fast as you like; the funnel only lets water out at one rate. Pour faster than that for long enough and it overflows.

The bucket fills with each request and drains at a fixed rate. It comes in two forms. As a queue, excess requests wait their turn and leave at a steady pace. As a meter, the bucket is only a level, and a request that finds it full is rejected straight away. Distributed twist: the level (or the queue) is the shared state, so it has the same atomicity requirement as a token bucket.

Used by: Shopify's REST Admin API, as a meter.

Cost-based bucket: the smart funnel

Not an algorithm so much as a change of unit. Instead of counting requests, count what each request costs, and take that many tokens from the bucket. The same Lua script works unchanged: the cost goes in as requested.

query_cost.go
// Computed from the query document before anything executes, so every
// instance that sees the same query arrives at the same number.
cost := QueryCost(fields, isMutation)

if !bucket.Take(ctx, "rl:"+shopID, cost) {
    // Throttled. Return the cost and the restore rate so the client can
    // work out for itself how long to wait.
}

Distributed twist: the cost calculation must be deterministic. If two instances could price the same query differently, the shared bucket would drain at a rate that depends on which server you happened to reach.

Used by: Shopify's GraphQL Admin API, GitHub's GraphQL API.

The algorithms at a glance

Algorithm Distributed state Best when
Token bucket Tokens + last refill time, updated by an atomic script Public APIs with bursty, well-behaved clients
Sliding window counter Two counters per key Huge key cardinality on a small memory budget
Fixed window One counter per key with a TTL Generous limits where a boundary burst is harmless
Leaky bucket A level, or a shared queue A backend that cannot absorb bursts at all
Cost-based bucket A token bucket, spent by computed cost Requests that vary wildly in how much work they do
Part III / Case Studies
06

Uber: Taking Redis Off the Hot Path

Scale: around 80 million requests per second across more than 1,100 services.

Uber's earlier limiters were Redis-based and configured service by service. At its scale that stopped working for several reasons at once. Configurations were inconsistent between teams, updates often meant redeploying servers, and the Redis limiters added latency and a fleet of their own to run. Keeping global counters that way would have needed hundreds of Redis clusters, with every request incrementing and reading a counter over the network.

The replacement, the Global Rate Limiter (GRL), lives in the service mesh that already relays RPCs between Uber's services. It is a three-tier feedback loop.

Uber GRL
Counts flow up, a drop ratio flows down, and the request never waits on either
counts per host, per second zone usage Mesh clients tier 1 · data plane decide each request locally report request counts Zone aggregators tier 2 collect every client in a zone compute zone-level usage Regional controllers tier 3 sum zones into global usage compute a drop ratio drop_ratio = (actual − limit) / actual, pushed every second Nothing is read over the network per request. The price is enforcement that trails traffic by 2–3 seconds.

What changed

  • No remote counter on the hot path. Each mesh client decides on a drop ratio it already holds in memory.
  • Soft limits instead of hard stops. Over the limit, GRL drops a percentage of traffic across all instances rather than slamming a bucket shut, so a caller slightly over its allowance loses slightly more requests, not all of them.
  • Limits set by data. A Rate Limit Configurator computes limits from weeks of historical peaks plus headroom, and pushes them through the same control plane.

Results

  • For one critical service, removing Redis cut P99.5 latency by up to 90% and P50 by about a millisecond.
  • A critical service rode out a 15× traffic surge, from 22K to 367K requests per second, without degradation.
What it reveals

At extreme scale even Redis becomes the bottleneck. The answer was not a faster store but no store on the request path at all: move the decision to the edge and let only the policy travel.

07

Databricks: Moving Beyond Redis

Databricks' original design was the textbook one: an Envoy ingress gateway calling a rate limit service, backed by a single Redis instance. Three problems followed.

  • Tail latency. P99 network latency to Redis of 10–20 ms in some cloud regions, and worse under heavy traffic.
  • A throughput ceiling. Past a point, optimisation stopped buying more headroom.
  • A single point of failure in that one Redis instance.
Databricks
From a remote call per request to local decisions and batched reports
BEFORE Envoy ingress every request ratelimit service p99 10–20 ms single Redis single point of failure every decision waits on a network hop AFTER client local, optimistic client local, optimistic client local, optimistic batch ~100 ms batch ~100 ms shard 1 shard 2 shard 3 shard N in-memory token buckets, sharded by Dicer decisions are local; only reports travel

The rebuild rests on three changes.

  • In-memory sharding. Using Dicer, Databricks' auto-sharding system, clients route directly to the server that owns their key, and that server holds the counts in memory. Redis is gone entirely.
  • Client-driven batch reporting. Clients rate limit locally and optimistically, and report aggregated counts periodically (every 100 ms, for example) instead of making a remote call per request. The server answers with guidance on what to reject.
  • Token bucket instead of fixed windows. A bucket counts continuously and can even go negative, which absorbs the overshoot that batching introduces and allows controlled bursts.

Result: tail latency improved by up to 10× in some cases, and server-side traffic now grows sub-linearly with request volume.

What it reveals

Redis is not the only way to share state. Sharded in-memory counters with batched reporting are the hybrid pattern in its purest form: accept a few hundred milliseconds of drift in exchange for no network hop per request.

08

Stripe: Four Limiters, Four Failure Modes

Stripe's API moves money, so an overloaded API is not just slow, it is failed payments. Its answer is not one limiter but four, each aimed at a different way things go wrong.

Stripe
Two limiters protect Stripe from a single user; two shedders protect critical traffic from everyone else
PER-USER LIMITERS FLEET-WIDE LOAD SHEDDERS request request rate token bucket N requests / sec concurrency in-flight requests e.g. 20 at once fleet usage reserves capacity for critical calls worker utilisation sheds by priority when workers strain API reject reject reject reject Redis token buckets, fail open
The worker utilisation shedder works through traffic classes from least to most important: test mode first, then GETs, then POSTs, and critical methods last.
Limiter Guards against
Request rate One user sending too many requests per second. Stripe calls this one by far the most important.
Concurrent requests One user holding too many slow, expensive requests open at once, which a per-second rate cannot see.
Fleet usage shedder Non-critical traffic eating the whole fleet. A fraction of infrastructure is always kept back for critical requests.
Worker utilisation shedder A fleet that is already struggling. Lower-priority traffic is shed first so that payments keep flowing.

The per-user limiters are token buckets in Redis, and they fail open: a bug in the limiter code or a Redis outage must never block requests.

What it reveals

One limit is not enough. Rate, concurrency and fleet capacity fail in different ways, and a limiter tuned for one is blind to the others. The most protection goes where a failure costs the most.

09

Cloudflare: Counting at the Edge

Cloudflare's rate limiting protects someone else's infrastructure. The whole point is to block excess traffic before it reaches the customer's origin server, and that pushes the limiter out to the edge, to hundreds of points of presence (PoPs) around the world handling several billion requests a day.

Synchronising counters between PoPs on every request would be prohibitively slow. Cloudflare avoids it with a property of its network: with anycast routing, traffic from a given IP address normally reaches the same PoP. So each PoP can count on its own, with no cross-PoP synchronisation at all.

Cloudflare
Anycast turns one global counting problem into many local ones
clients near Frankfurt clients near Singapore clients near São Paulo anycast anycast anycast PoP · Frankfurt its own counters PoP · Singapore its own counters PoP · São Paulo its own counters no sync no sync blocked at edge blocked at edge blocked at edge customer origin: sees only traffic under the limit

Within a PoP, Cloudflare uses the sliding window counter from section 5. The alternatives do not fit. A log of per-request timestamps for every source hitting every domain is far too much memory, and a fixed window is too easy to game at the boundary. Two counters per key fit in memory and are hard to exploit.

Cloudflare checked how much accuracy the approximation costs. Over 400 million requests from 270,000 sources, 0.003% of requests were wrongly allowed or wrongly limited, with an average difference of 6% between the real rate and the estimate.

What it reveals

The design follows from whose infrastructure is being protected. Because Cloudflare sits in front of someone else's origin, blocking has to happen before the origin pays for a request. That forces the limiter to the edge, the edge forces distribution, and distribution forces approximation.

10

AWS API Gateway: The Limiter as a Product

AWS is not rate limiting its own API here. It is selling you a rate limiter, as a set of settings in a console. That changes what matters most.

API Gateway uses a token bucket, and the reason is easiest to see from the customer's side. A token bucket has exactly two knobs: a rate, the steady requests per second, and a burst, the bucket's capacity. Both are simple enough to put in a form. By default, an account gets 10,000 requests per second with a burst of 5,000, per Region, and the settings nest. API Gateway applies them in this order:

1
Per-client or per-method limits in a usage plan, keyed by API key. Usage plans can also carry quotas per day, week or month.
2
Per-method limits set on an API stage.
3
Account-level limits per Region, across every API in the account.
4
AWS Regional throttling, across all accounts, which customers cannot change.
Targets, not ceilings

The documentation is explicit that throttles and quotas are applied on a best-effort basis and should be treated as targets rather than guaranteed request ceilings. That is a distributed limiter being honest about itself: enforcement across AWS's fleet is approximate, and limits can occasionally be overrun.

What it reveals

When the rate limiter is the product, explainability outranks sophistication. Two knobs per scope that a customer can reason about beat a cleverer algorithm they cannot.

11

LaunchDarkly: Three Limiters for Three Jobs

LaunchDarkly did not pick one design. It built three, each placed at a different point on the accuracy-versus-coordination line, because its workloads need different things.

Limiter Protects Accuracy Coordination When Redis fails
Centralized The app.launchdarkly.com APIs Exact Redis checked on every request Fails open: all traffic allowed
Floodgate Event ingestion, events.launchdarkly.com Approximate Local per request; periodic sync of node count and overall count Fails safe
Poisson Context indexing in a Flink pipeline Approximate None Cannot fail: nothing to reach

Floodgate

Written in Go on top of Redis, but Redis never appears on the request path. Every decision is made locally on the machine serving the request. In the background, each node periodically pulls the current node count and the overall count from central storage, so its share of the global limit follows the fleet as it scales. Auto-scaling tends to happen in small steps, so the drift between syncs is not material.

Poisson

The most interesting of the three. The pipeline has a fixed number of shards and traffic is spread across them, so the count on any one shard is random. A simple limit ÷ shards split would wrongly reject traffic whenever a shard got more than its fair share. Instead LaunchDarkly precomputes the 95% inverse CDF of the Poisson distribution for each limit and shard count, and uses that as each shard's local limit. The result is a 95% chance of never over-enforcing, with no coordination at all. As LaunchDarkly puts it, it is not really a distributed system, so it cannot fail.

What it reveals

There is no one-size-fits-all limiter, even inside one company. Exact or approximate, coordinated or not, is a per-workload decision, and a bit of statistics can stand in for a lot of coordination.

12

Shopify: Limit What Actually Costs You

Shopify uses two different models on two APIs, and the reasons for each are clear.

API Model Unit Why
REST Admin Leaky bucket Requests Bursts drain at a steady rate, and requests that find the bucket full are rejected with a 429.
GraphQL Admin Cost-based bucket Calculated query cost One query can ask for a single field or for hundreds of orders with their line items. Counting requests means nothing when requests differ that much.

For GraphQL, Shopify calculates a cost for each query before running it and deducts that from the bucket. The documented rules are simple. Scalar and enum fields cost nothing. Each object costs 1 point. A connection is sized by the page it asks for (first or last), so page size multiplies everything nested inside it. A mutation costs 10. Points restore at a rate set by the plan: 100 points per second on Standard, 1,000 on Shopify Plus.

A throttled response includes the query's cost and the restore rate, so a well-written client can work out exactly how long to wait instead of retrying blind.

What it reveals

The unit you limit should match the unit that actually costs you something. Once request count stops correlating with work, a request limit is only protecting you by accident.

13

Lyft and Envoy: The Reference Implementation

Lyft open-sourced ratelimit, a Go/gRPC service that implements Envoy's rate limit API. It became the reference implementation for Envoy's global rate limiting, which makes it the design many teams deploy first, often without knowing it.

  • Domains and descriptors. A domain is a container for a set of limits. A descriptor is a list of key/value pairs, such as database=users, that selects which limit applies. One request can carry several descriptors and is checked against every rule that matches.
  • Fixed windows in Redis. Limits are written as requests per second, minute, hour or day, and counted in Redis. Commands are pipelined, so the several counters one request touches cost one write and one read.
  • A local cache for over-limit keys. An optional in-process cache remembers keys that are already over their limit, so a client hammering past its allowance is rejected without another trip to Redis.
Where the hybrid shows up

Envoy also ships a local rate limit filter, a token bucket inside each proxy, and it can run in front of the global service. The local limit absorbs the obvious floods cheaply and the global service handles the fleet-wide count. Put together with the over-limit cache, even this mostly centralized design keeps a local tier to protect its shared store.

Part IV / Assessment
14

Side by Side

System Mechanism Unit limited Enforced at Coordination per request Driving constraint
Uber GRL Probabilistic drop ratio Requests per caller / procedure Service mesh data plane None; ratios pushed each second Remote counters at 80M RPS
Databricks Token bucket, in memory Requests Sharded limiter service None; batched reports Redis tail latency and SPOF
Stripe Token buckets + load shedders Requests, in-flight, fleet capacity Application tier Redis Keeping payments flowing
Cloudflare Sliding window counter Requests Each edge PoP None across PoPs Block before the origin pays
AWS API Gateway Token bucket Requests (+ quotas) Managed gateway Internal, best-effort Explainable as a product
LaunchDarkly Three limiters Requests / events Per service Redis, periodic sync, or none Different workloads
Shopify REST Leaky bucket Requests Application tier Not published Smooth load from apps
Shopify GraphQL Cost-based bucket Calculated query cost Application tier Not published Queries vary enormously in cost
Lyft ratelimit Fixed window counters Requests per descriptor Envoy → gRPC service Redis, with local over-limit cache A general-purpose default
Positioning
Where each design sits between coordination and approximation
approximate + centralized approximate + local exact + centralized exact + local centralized coordination local decisions exact approx. Stripe Lyft ratelimit Shopify GraphQL AWS API Gateway Databricks LD Floodgate Cloudflare Uber GRL LD Poisson centralized hybrid decentralized
Positions are a reading of each design, not measurements. The shape is the point: nothing sits in the exact-and-local corner, because deciding without coordination means giving up some exactness.
15

What Their Choices Reveal

Read side by side, these architectures share a handful of principles.

At extreme scale, the shared store becomes the bottleneck

Uber and Databricks both began with Redis on the request path and both took it off. Uber moved decisions into the service mesh and pushed only a drop ratio; Databricks moved counts into sharded memory and batched the reports. Neither found a faster store. Both stopped consulting one per request.

Approximation is a feature you size, not a flaw you tolerate

Cloudflare measured its error at 6% on average. LaunchDarkly picked a 95% confidence level on purpose. AWS documents its limits as targets. The mature designs do not pretend to be exact; they state how inexact they are, and choose that on purpose.

Decide your failure mode before it happens

  • Stripe and LaunchDarkly's Centralized limiter fail open: if Redis is gone, traffic flows.
  • Floodgate fails safe, because its decisions never depended on reaching Redis in the first place.
  • Poisson has no dependency to lose.

Fail open favours availability; fail closed favours protection. Whichever you pick, pick it deliberately, and alert when it happens.

Cardinality drives architecture

A handful of keys, such as per-service or per-plan limits, can afford an exact central counter. Millions of keys, such as per-IP limits on every domain, cannot, and push you towards compact approximations and local ownership. Split your limits by cardinality before choosing one design for all of them.

Limit the thing that actually costs you

  • GitHub's secondary limits cap CPU time, not just request count.
  • Shopify's GraphQL API limits calculated query cost.
  • Stripe limits concurrency alongside rate, and sheds test mode and GETs before POSTs and critical calls.

All three got there because request count stopped tracking request expense.

Separate the commercial limit from the protective one

AWS makes the split visible: a usage plan's monthly quota and its per-second throttle are different settings. "You have used your plan" and "you are going too fast right now" need different responses from the client, and folding them into one number helps neither.

Predictability is a choice with a cost on both sides

Discord returns detailed rate limit headers so a well-behaved client can avoid ever being limited. GitHub's secondary limits deliberately say less, so abusive clients cannot tune themselves to sit just under the line. Both are defensible; they serve different client populations.

The algorithm follows the traffic, not the other way round

  • Token buckets suit Stripe's API, where short bursts from legitimate clients are normal.
  • Shopify REST uses a leaky bucket to hand its backend a steady flow.
  • Cloudflare uses a sliding window counter because per-request logs are impossible at its cardinality.
  • Uber drops a percentage instead of hard-stopping, so a caller slightly over its limit loses slightly more traffic, not all of it.
The one-line version

Start with an atomic counter in a shared store. Know the traffic level where that round trip will hurt, and when you reach it, move the decision closer to the request and decide how much accuracy you are willing to give up for it.

16

References

  1. Uber Engineering, Uber's Rate Limiting System. uber.com
  2. Databricks Engineering, High Performance Ratelimiting at Databricks. databricks.com
  3. Stripe Engineering, Scaling your API with rate limiters. stripe.com
  4. Cloudflare, How we built rate limiting capable of scaling to millions of domains. blog.cloudflare.com
  5. AWS, Throttle requests to your REST APIs for better throughput in API Gateway. docs.aws.amazon.com
  6. LaunchDarkly Engineering, A tale of three rate limiters. launchdarkly.com
  7. Shopify, API rate limits. shopify.dev
  8. Lyft, ratelimit. github.com/lyft/ratelimit
  9. Envoy, Rate limiting. envoyproxy.io
  10. GitHub, Rate limits for the REST API. docs.github.com
  11. Discord, Rate Limits. discord.com
  12. Redis, Rate limiting. redis.io

Specific numeric limits change often. The architectural patterns here are stable; the numbers are not, so check each provider's live documentation before building against them.