← Blog

Rate Limiting · Redis · Distributed Systems

How Accurate Should a Distributed Rate Limiter Be When the Limiter Itself Can Fail?

Distributed rate limiting is not just a counter problem. This article examines Redis-backed limits, token buckets, concurrency controls, fail-open and fail-closed behavior, local fallbacks, regional budgets, replica staleness, hot keys, approximation error, load shedding, and how to choose the right consistency policy for the resource being protected.

Aashaya Aryal

December 2, 2023 · 23 min read

Updated August 11, 2026

Empty multi-lane roadway passing beneath a large canopy with concrete barriers positioned across the lanes.

A rate limiter sounds like arithmetic.

Allow:

TEXT
100 requests / minute

Count requests, and when request 101 arrives:

TEXT
HTTP 429 Too Many Requests

That model works beautifully while the application runs on one machine and one process owns the counter.

Then the service scales horizontally.

Diagram

Client

Load Balancer

API Instance A

API Instance B

API Instance C

Now the obvious question appears:

Where does the counter live?

If every instance maintains its own state, a client with a nominal limit of:

TEXT
100 requests/minute

may effectively receive:

TEXT
100 × number_of_instances

requests before the fleet agrees that the limit has been exceeded.

So the counter moves into Redis.

That fixes one problem.

Then Redis gets slow. Or a replica is stale. Or a shard fails. Or one tenant becomes such a hot key that the rate limiter itself becomes the bottleneck. Or a network partition prevents part of the API fleet from reaching the state used to decide whether requests should be admitted.

At that point, the interesting question is no longer:

“Which rate-limiting algorithm should we use?”

It becomes:

“How much enforcement accuracy are we willing to sacrifice so the mechanism protecting availability does not itself destroy availability?”

That is what makes distributed rate limiting interesting.

The implementation may contain an INCR.

The architecture contains a consistency and failure policy.

The Counter Becomes a Consistency Problem

Start with one Node.js process and a simple per-tenant rule:

TEXT
100 requests / second

A local implementation might look like:

TS
const counters = new Map<string, { count: number; resetAt: number }>();

function allow(tenantId: string, now = Date.now()): boolean {
  const current = counters.get(tenantId);

  if (!current || now >= current.resetAt) {
    counters.set(tenantId, {
      count: 1,
      resetAt: now + 1_000,
    });

    return true;
  }

  if (current.count >= 100) {
    return false;
  }

  current.count++;

  return true;
}

For one process, the model is straightforward:

TEXT
client
→ one counter
→ one decision

The local state is authoritative.

Now deploy several instances behind a load balancer.

Diagram

One Tenant

Load Balancer

Instance A

counter = 70

Instance B

counter = 62

Instance C

counter = 68

Each server independently sees:

TEXT
less than 100

and allows traffic.

Globally:

TEXT
70 + 62 + 68 = 200

requests have already occurred.

The limiter has become approximate without anyone intentionally deciding that approximation was acceptable.

That is the first distributed-rate-limiting problem:

The thing being limited is global, while the observations are local.

Shared coordination can fix that.

Shared coordination also introduces latency, availability, consistency, and failure-domain costs.

Those costs become part of the limiter design just as much as the quota itself.

Redis Solves Coordination, Not the Entire Design

A shared Redis deployment gives the API fleet one logical counter:

Diagram

Clients

Load Balancer

API A

API B

API C

Redis

Shared Counter

Each request can perform something like:

TEXT
INCR ratelimit:tenant_42

Now the instances agree on one count.

Redis is attractive here because increment operations are atomic, and its documentation explicitly presents rate limiting as a common use case. When the limiter requires several operations to make one decision, server-side atomic execution through Lua or an equivalent primitive can prevent clients from racing through a read/decide/update sequence.

A fixed-window key might look like:

TEXT
ratelimit:tenant_42:202610022343

with an expiration.

A simple implementation could be:

TS
const count = await redis.incr(key);

if (count === 1) {
  await redis.expire(key, 60);
}

return count <= limit;

The code is small.

The edge cases are not.

Two commands can create one race

Suppose:

TEXT
INCR key

succeeds and the application crashes before:

TEXT
EXPIRE key

executes.

The counter may now live far longer than intended.

Redis documents this kind of race, which is why the increment and first expiration are commonly made atomic.

For example:

LUA
local current = redis.call("INCR", KEYS[1])

if current == 1 then
    redis.call("EXPIRE", KEYS[1], ARGV[1])
end

return current

Now:

TEXT
increment
+
initial expiration

form one atomic server-side operation from the clients' perspective.

Current Redis versions also provide newer primitives such as INCREX that can combine increment, bounds, and expiration behavior in a single command.

That solves an important local atomicity problem.

It still does not answer what happens when Redis is stale, unavailable, partitioned, or overloaded.

Choose the Algorithm Around the Failure You Care About

Algorithm discussions often begin with:

TEXT
fixed window
sliding window
token bucket
leaky bucket

I think that conversation is more useful after we define what the limiter is trying to protect.

Fixed windows are simple but burst around boundaries

Suppose the policy is:

TEXT
100 requests / minute

A client sends 100 requests at:

TEXT
12:00:59

and another 100 at:

TEXT
12:01:00

Both windows are valid.

The client has effectively sent:

TEXT
200 requests

across roughly one second.

timeline
    title Fixed Window Boundary
    12:00:59 : 100 requests allowed
    12:01:00 : Counter resets
             : 100 more requests allowed

That may be completely acceptable for:

TEXT
billing-plan fairness

It may be dangerous if the limiter exists because:

TEXT
the database cannot safely absorb
200 requests in one burst

The algorithm should follow the actual failure mode.

Token buckets separate sustained rate from burst capacity

A token bucket lets us express:

TEXT
sustained rate = 100 req/s
burst capacity = 150 requests

Tokens regenerate over time and requests consume them:

Diagram

token available

empty

Refill Tokens

Token Bucket

Incoming Request

Allow

Reject

Stripe has described using token-bucket rate limiting in production, including temporary burst capacity above a steady rate.

That distinction is useful because real traffic is rarely perfectly smooth.

A tenant doing:

TEXT
0
0
0
40 requests
0

may still be perfectly well-behaved even if its long-term target rate is modest.

Rejecting every short burst can reduce usability without meaningfully protecting the system.

The important question is therefore not whether one algorithm is theoretically superior.

It is whether the chosen algorithm represents the resource or policy the limiter is actually trying to enforce.

Rate, Concurrency, and Load Are Different Signals

Request rate is only one kind of pressure.

Suppose one endpoint takes:

TEXT
10 ms

and another takes:

TEXT
10 seconds

Both receive:

TEXT
10 requests/sec

The cheap endpoint has roughly:

TEXT
0.1 requests

concurrently active on average.

The expensive endpoint can have around:

TEXT
100 requests

active at once.

The request rate is identical.

The resource pressure is completely different.

Stripe has described using separate concurrent-request limiters for expensive operations because requests-per-second limits alone did not adequately control resource contention.

That gives us an important distinction:

TEXT
rate limit
=
how quickly new work may enter

versus:

TEXT
concurrency limit
=
how much work may be in progress

Latency can invalidate an apparently safe RPS limit

Suppose an operation normally takes:

TEXT
5 seconds

and the service can safely execute:

TEXT
20 concurrently

A rate cap of:

TEXT
4 requests/sec

appears consistent with that capacity.

Then dependency latency doubles:

TEXT
10 seconds

and:

TEXT
4 req/sec × 10 sec
≈ 40 concurrent requests

The service overloads without violating the request-rate limit at all.

A concurrency limit adapts naturally:

TEXT
20 active
→ request 21 is rejected or queued

If the protected resource fails because too much work is simultaneously active, concurrency is often the more direct safety signal.

Rate limiting is not load shedding

Now suppose every tenant remains below its quota, but database latency triples.

No customer is abusing the API.

The fleet can still be overloaded.

Static quotas answer:

TEXT
How much traffic may this caller send?

Load shedding answers:

TEXT
How much work can the system accept right now?

Those are different decisions.

Stripe describes using several layers together: request-rate limits, concurrent-request limits, and fleet-level load shedding that preserves capacity for more critical API operations.

A mature design should not assume:

TEXT
rate limiter present
=
overload solved

Rate limiting is one form of admission control.

It is not the entire capacity-control strategy.

The Hard Question Is What Happens When the Limiter Fails

Now return to the shared-state architecture:

TEXT
API instances
→ Redis
→ allow / deny

Every request depends on Redis.

Redis becomes unavailable.

What should happen?

There are two obvious extremes.

Fail closed

TEXT
cannot check limiter
→ reject request

Fail open

TEXT
cannot check limiter
→ allow request

Neither is universally correct.

Failing closed preserves enforcement

Consider a login endpoint where the limiter exists to reduce:

TEXT
credential stuffing
brute-force attempts

If Redis disappears and the application responds by allowing unlimited attempts, the security property disappears exactly while one of the components enforcing it is unhealthy.

For that kind of boundary, failing closed—or falling back to another restrictive local mechanism—may be appropriate.

Diagram

unavailable

Client

Login API

Redis Rate Limiter

Reject / Restrict

The cost is obvious:

TEXT
Redis outage
→ login availability decreases

That may still be the correct trade.

What matters is that it is intentional.

Failing open preserves application availability

Now consider a soft fairness quota on an ordinary API.

If the limiter is unreachable for 20 seconds, rejecting every API call could create a much larger outage than temporarily allowing a few clients to exceed their normal quotas.

Stripe explicitly recommends integrating limiters so defects in limiter logic or Redis failure do not unnecessarily take down the API itself.

In that case:

TEXT
limiter unavailable
→ allow request
→ emit degraded signal

may be a better policy.

The key principle is:

Fail-open versus fail-closed is a product and reliability decision, not a Redis decision.

Different endpoints can reasonably choose different answers.

OperationWhy it is limitedPlausible failure behavior
Login attemptsSecurity / abuseRestrict locally or fail closed
Password resetSecurity / abuseRestrictive fallback
Public searchFairness / costFail open with local cap
Report generationExpensive computeLocal concurrency cap or reject
Health checkAvailabilityUsually bypass limiter
Payment creationDownstream protection / correctnessDepends on protected dependency
Static metadataFairnessOften fail open

A single global:

TS
catch {
  return true;
}

or:

TS
catch {
  return false;
}

usually throws away too much policy information.

Local Fallback Trades Precision for Availability

A useful middle ground is:

TEXT
shared limiter healthy
→ use Redis-backed global policy

shared limiter unavailable
→ use a coarse local emergency policy

For example:

TS
async function allowRequest(key: string): Promise<boolean> {
  try {
    return await distributedLimiter.allow(key);
  } catch {
    return localEmergencyLimiter.allow(key);
  }
}

Suppose ten application instances each fall back to:

TEXT
20 requests/sec

The fleet might temporarily allow something near:

TEXT
200 requests/sec

instead of the intended global limit.

That is inaccurate.

It may still be substantially safer than:

TEXT
unlimited traffic

or:

TEXT
zero traffic

The architecture has consciously traded precision for availability.

This is an important shift in mindset: during degraded operation, the goal may not be to preserve the exact nominal quota. The goal may be to preserve the reason the quota exists.

If the limiter exists for fairness, a temporary 10–20% overshoot may be tolerable.

If it exists to prevent one database from collapsing, the local fallback needs to remain conservative enough to protect that database even when global coordination is gone.

If it exists as a security boundary, approximation may be unacceptable.

The fallback should preserve the invariant, not merely imitate the normal algorithm.

Perfect Global Accuracy Requires Global Coordination

Now spread the API across several regions:

TEXT
Virginia
Oregon
Frankfurt
Tokyo
Sydney

One tenant has a truly global quota:

TEXT
1,000 requests/sec

To enforce that perfectly, every region needs sufficiently fresh knowledge of what all the others have admitted inside the same logical window.

That implies coordination.

Diagram

US API

EU API

Asia API

Global Limiter State

The benefit is improved accuracy.

The costs may include:

TEXT
higher request latency
cross-region dependency
larger blast radius
lower partition tolerance

If the shared limiter becomes unreachable, several otherwise independent regions may be affected together.

The rate limiter has become a global failure domain.

That might be justified if the quota represents a hard security or correctness invariant.

It may be an unnecessary price for an ordinary fairness policy.

Perfect enforcement is not free.

The more global the truth needs to be, the more coordination the system usually needs to buy.

Approximation Can Be the Better Engineering Choice

Cloudflare encountered the general problem while designing rate limiting across millions of domains.

Purely local counters were too inaccurate because requests could land on different machines.

Globally centralizing the counter for every request would have introduced undesirable coordination and latency.

Cloudflare instead localized counting within a point of presence and used an approximate sliding-window algorithm. In an analysis covering roughly 400 million requests from 270,000 sources, it reported around 0.003% incorrect limiting decisions for that workload.

That result illustrates a broader principle:

A slightly approximate limiter that is fast, local, and available may be better than a mathematically exact limiter that introduces a fragile distributed dependency into every request.

The useful question is not:

TEXT
Is the limiter exact?

It is:

TEXT
How often is it wrong?

In which direction?

What does that error cost?

False allows and false rejects have different consequences

An approximate limiter can make two kinds of mistakes.

A false allow means:

TEXT
request should have been rejected
but was admitted

A false reject means:

TEXT
request should have been admitted
but was rejected

For a soft fairness quota, a false allow may be nearly harmless while a false reject creates unnecessary customer pain.

For a brute-force defense, the cost may be reversed.

Cloudflare's analysis is useful partly because it measured incorrect decisions rather than treating approximation as an abstract property.

Approximation should be evaluated economically and operationally.

Not philosophically.

Ask what being wrong by 5% means

Suppose the intended limit is:

TEXT
100 requests/sec

and the distributed implementation occasionally permits:

TEXT
105 requests/sec

For:

TEXT
free-tier fairness

that may not matter.

For:

TEXT
a dependency that becomes unstable
above 100 concurrent operations

it may matter greatly.

This is why fairness quotas and hard capacity boundaries should not automatically share the same consistency design.

Accuracy is a product requirement.

Not every rate limit deserves the same one.

Regional Budgets Trade Utilization for Independence

One way to avoid globally coordinating every request is to divide a global budget into regional allocations.

For:

TEXT
global quota = 1,000 req/sec

we might assign:

TEXT
US      400
EU      250
Asia    250
Other   100
Diagram

Global Budget

1000/s

US

400/s

EU

250/s

Asia

250/s

Other

100/s

Each region can now make decisions locally.

That improves:

TEXT
latency
regional independence
partition tolerance
blast-radius containment

But suppose traffic shifts:

TEXT
US needs 700
Asia needs 20

Capacity can become stranded in a quiet region while another rejects useful traffic.

Regional budgets trade:

TEXT
global utilization efficiency

for:

TEXT
local independence

That may be a very good trade.

It is still a trade.

A more adaptive system can periodically rebalance budgets, but that reintroduces coordination—just at a slower control-plane timescale rather than on every request.

That is often a much better place to pay the coordination cost.

Replication and Sharding Introduce New Failure Modes

A Redis-backed limiter can be locally atomic and still globally inconsistent.

Suppose Redis uses:

TEXT
primary
+
read replicas

Writes go to the primary.

Reads can come from replicas.

Replication is not necessarily instantaneous.

Request A updates the primary so that:

TEXT
remaining = 0

Request B reads a lagging replica and still sees:

TEXT
remaining = 2

and allows traffic that a perfectly current view would have rejected.

GitHub encountered this class of complexity while moving API rate limiting to sharded and replicated Redis. Its design used primaries for writes, replicas for reads, application-side sharding, and Lua for atomic operations, which then required dealing with stale state and inconsistencies visible in rate-limit metadata.

The lesson is simple:

Atomicity on one Redis node does not make the entire distributed limiter strongly consistent.

The decision and its metadata should share a snapshot

Clients often receive headers such as:

TEXT
X-RateLimit-Limit
X-RateLimit-Remaining
X-RateLimit-Reset

Imagine the server:

  1. evaluates the request,
  2. rejects it,
  3. performs another Redis lookup to generate headers.

If the rate-limit window resets between steps 1 and 3, the response might effectively say:

TEXT
Request rejected because the limit was exceeded.

Remaining: 100

GitHub encountered this kind of inconsistency and changed its design so the limiting decision and the metadata explaining that decision came from the same operation.

That reveals a useful general rule:

If a decision and the explanation of that decision must agree, derive them from the same snapshot of state.

Hot tenants become hot Redis keys

Suppose most tenants generate:

TEXT
10 requests/sec

while one very large tenant generates:

TEXT
100,000 requests/sec

All of those decisions may touch:

TEXT
rate:tenant:enterprise_42

Sharding distributes many keys across the cluster:

Diagram

Tenant A

Tenant B

Tenant C

Huge Tenant

Redis Shard 1

Redis Shard 2

Redis Shard 3

Cluster-wide CPU can look healthy while shard 2 is saturated.

This is the same lesson that appears in Cassandra, caching, queues, and many other distributed systems:

Balanced keys do not guarantee balanced work.

Sharding complicates multi-dimensional limits

Suppose one request must satisfy:

TEXT
per-user limit
+
per-tenant limit
+
global load limit

Those counters may live on different Redis shards.

One Lua script cannot atomically manipulate arbitrary keys spread across independent shards.

You now have to decide whether to:

TEXT
co-locate related counters
perform independent checks
accept partial updates
split admission into multiple stages

For example:

Diagram

Request

User Limiter

Tenant Limiter

Global Load Shedder

Application

Each stage can have a different scope, consistency requirement, and failure policy.

Trying to turn every form of rate limiting into one globally atomic transaction usually creates a worse architecture.

Identity and Cost Determine What the Limit Means

A limiter only makes sense once we define who or what is consuming the budget.

Possible keys include:

TEXT
IP address
user ID
API key
tenant ID
device ID
endpoint

Each represents a different policy.

IP-based limits can unfairly penalize:

TEXT
large offices
universities
carrier NAT
shared proxies

because many legitimate users may share one public address.

User-level limits require authentication before the limiter can identify the user.

API-key limits fit developer APIs, but a compromised key can consume the account's entire budget.

Tenant-level limits protect platform fairness while still allowing one user inside the tenant to monopolize the shared allowance.

Many systems therefore need several layers:

TEXT
IP abuse protection
+
user/account quota
+
tenant quota
+
endpoint-specific safety control

Rate limiting is policy expressed through counters.

One request is not necessarily one unit of cost

Consider:

TEXT
GET /profile

CPU: 1 ms
DB: one indexed read

and:

TEXT
POST /reports/export

CPU: 2 seconds
DB: millions of rows scanned

If both consume:

TEXT
1 token

the limiter is pretending that they cost the system the same amount.

One alternative is weighted accounting:

TEXT
/profile        = 1 token
/search         = 3 tokens
/report/export  = 50 tokens

with something like:

TS
await limiter.consume({
  key: tenantId,
  cost: endpointCost,
});

That can work.

It also requires a credible model of cost.

For extremely expensive operations, a direct concurrency limit may be simpler and more closely coupled to the resource being protected.

The right unit is not always "one request."

A Rejection Is Part of a Feedback Protocol

Returning 429 is not the end of the design.

The client needs to know what to do next.

A response may include:

HTTP
HTTP/1.1 429 Too Many Requests
Retry-After: 8

The client should not interpret that as:

TEXT
429
→ retry immediately
→ 429
→ retry immediately
→ 429

because the rate limiter has now become the source of a retry storm.

Rate limiting works best as a feedback loop:

TEXT
clear rejection
+
retry guidance
+
backoff
+
jitter

The exact retry policy depends on the API, but the principle is important:

Rate limiting is communication about capacity, not merely rejection.

Retry timing can synchronize the next spike

Suppose:

TEXT
10,000 clients

all receive:

TEXT
Retry-After: 10

and every client retries at exactly 10 seconds.

The system has moved one burst ten seconds into the future.

Adding jitter spreads that recovery traffic:

TEXT
retry after approximately 10 seconds
+
random variation

Capacity-control mechanisms interact.

A sophisticated server-side limiter can still be defeated by a poorly behaved client retry strategy.

Observe the Limiter as Its Own Distributed System

A rate limiter that sits on every request is production infrastructure.

It deserves its own telemetry.

At minimum, I would want:

TEXT
rate_limit_checks_total
rate_limit_allowed_total
rate_limit_rejected_total
rate_limit_backend_errors_total
rate_limit_fallback_total
rate_limit_latency

with useful dimensions such as:

TEXT
limiter type
endpoint
tenant class
region
failure mode

A healthy snapshot might look like:

TEXT
request limiter rejections:      2.1%
concurrency limiter rejections:  0.02%
Redis errors:                    0
local fallback decisions:        0

During a Redis incident:

TEXT
Redis errors:                18%
local fallback decisions:    18%
API availability:          99.9%

Now operators can see that:

TEXT
limiter degraded

without concluding:

TEXT
API unavailable

That is far better than learning:

TEXT
Redis is down

from:

TEXT
the entire API is down

Measure approximation explicitly

If the architecture intentionally allows approximate decisions, I want to know how often those decisions differ from the ideal reference behavior.

Measure:

TEXT
false allows
false rejects

separately.

Those errors can carry very different costs depending on the limiter's purpose.

The observability should match the policy.

A security limiter should surface enforcement gaps differently from a soft fairness quota.

Build the Policy Before Choosing the Algorithm

Before choosing:

TEXT
fixed window
sliding window
token bucket

I would first answer:

TEXT
What resource or policy is this protecting?

What happens if we allow too much?

What happens if we reject too much?

How global does the limit really need to be?

How much burst is acceptable?

What happens if the state store fails?

Can enforcement be approximate?

Should failure be open, closed,
or locally degraded?

Do we need to control rate,
concurrency, or current fleet load?

Only then would I choose the algorithm.

Otherwise, the design conversation starts one abstraction too low.

A token bucket is an implementation of a policy.

It is not the policy itself.

Accuracy is a product requirement

Consider three limits.

A free-tier API quota:

TEXT
1,000 requests/day

If races occasionally permit:

TEXT
1,004

that may be irrelevant.

A database-protection limit:

TEXT
maximum 100 concurrent exports

If the system occasionally allows:

TEXT
300

the database may become unstable.

A login-abuse control:

TEXT
5 failed attempts/minute

If limiter failure suddenly allows:

TEXT
100

the security assumption has disappeared.

All three are described as:

TEXT
rate limits

but they have radically different correctness requirements.

That is why I would define the cost of enforcement error before deciding how strongly consistent the counter needs to be.

A Practical Layered Design

For a multi-tenant API, I would think about admission control as several independent stages:

Diagram

Request

Coarse IP Abuse Limit

Authenticate

Per-User Limit

Per-Tenant Redis Token Bucket

Expensive Endpoint

Concurrency Limit

Fleet Load Shedder

Business Logic

Not every request needs every stage.

Each stage answers a different question.

TEXT
IP limiter:
Is this source obviously abusive?

User limiter:
Is this principal behaving reasonably?

Tenant limiter:
Is one customer monopolizing shared capacity?

Concurrency limiter:
Can this expensive operation start safely?

Load shedder:
Can the fleet accept this work right now?

That is much more expressive than one global:

TEXT
100 req/sec

counter.

Define degraded behavior beside the normal policy

For ordinary API fairness:

TEXT
Redis available
→ global token bucket

Redis unavailable
→ local conservative bucket
→ emit degraded metric

For expensive report generation:

TEXT
Redis unavailable
→ local concurrency cap
→ reject excess

For login attempts:

TEXT
Redis unavailable
→ stricter local security threshold
or fail closed

For health checks:

TEXT
bypass rate limiter

The failure mode becomes visible application policy rather than an accidental property buried in middleware.

A simplified wrapper might look like:

TS
type RateLimitMode = "normal" | "degraded-local" | "fail-closed";

async function checkLimit(request: RequestContext): Promise<RateLimitDecision> {
  const policy = policies.forEndpoint(request.route);

  try {
    return await distributedLimiter.check({
      key: policy.key(request),
      limit: policy.limit,
      cost: policy.cost(request),
    });
  } catch (error) {
    metrics.rateLimiterBackendError.inc();

    switch (policy.failureMode) {
      case "fail-open":
        return {
          allowed: true,
          mode: "degraded-local",
        };

      case "local-fallback":
        return localLimiter.check(policy.key(request), policy.localLimit);

      case "fail-closed":
        return {
          allowed: false,
          mode: "fail-closed",
        };
    }
  }
}

The exact implementation is not the interesting part.

What matters is that failure semantics are explicit.

I would not want a system's admission-control policy to depend on a forgotten:

TS
catch {
  return true;
}

whose rationale exists only in somebody's memory.

The Limiter Must Have a Smaller Blast Radius Than the System

A control mechanism should be proportionate to the thing it protects.

Suppose:

TEXT
GET /health

takes:

TEXT
1 ms

while consulting a distant global Redis cluster adds:

TEXT
25 ms

The rate-limiting decision costs 25 times as much latency as the operation itself.

That is probably the wrong boundary.

Likewise, if five independently deployed regions all synchronously depend on one global Redis cluster for every request, the architecture has turned:

TEXT
5 regional failure domains

into:

TEXT
1 global limiter failure domain

That may be justified for a hard global security invariant.

For ordinary API fairness, it can be a terrible trade.

A reliability mechanism should not casually become the largest single point of failure in the system it was introduced to protect.

Cloudflare's localized approach is useful partly because it avoids paying global coordination cost on every request.

Regional budgets do something similar by moving coordination into a slower control plane.

Local fallback does it during failure.

These designs all accept some form of approximation or temporary inefficiency in exchange for keeping failures contained.

Sometimes technically wrong is operationally better

Suppose two regions briefly disagree.

Region A believes:

TEXT
remaining = 3

while region B believes:

TEXT
remaining = 1

Two requests slip through beyond the exact quota.

The limiter was technically wrong.

But:

TEXT
service remained available
latency remained low
the protected dependency remained healthy
the customer noticed nothing

Now compare that with a strongly coordinated global design that blocks every request during a network partition.

Its counter may be mathematically correct.

Its service is unavailable.

Which architecture is more correct depends entirely on the invariant being protected.

For many fairness policies, bounded overshoot is the better failure mode.

For a security boundary, it may not be.

Distributed engineering is often about deciding which kind of wrongness the system can safely tolerate.

The Invariants I Want

For a distributed rate limiter, the guarantees I care about are closer to:

TEXT
One API instance must not independently grant
the entire intended global quota.

Limiter state updates must not lose increments
because of local command races.

A limiter-backend outage must have an explicit
failure policy.

One limiter failure must not unintentionally take
down the entire API unless fail-closed behavior
is deliberate.

Security limits and fairness limits should not
automatically share the same failure semantics.

Expensive operations should be protected by
capacity controls tied to their actual resource use.

Approximation error should be measured as
false allows and false rejects.

Rate-limit metadata returned to a client should
describe the same decision that admitted or
rejected the request.

The limiter itself should expose latency,
errors, fallback activity, and rejection volume.

A globally exact quota should justify the latency,
availability, and blast-radius cost of global
coordination.

A hot tenant must not be able to turn the limiter
itself into the system bottleneck.

Degraded operation should preserve the underlying
invariant even if exact quota accounting is lost.

Those guarantees are much more useful than:

TEXT
we use Redis INCR

because INCR solves the easy part.

The difficult part is deciding what the system should do when the state behind that increment is partial, stale, inaccessible, or expensive to coordinate.

Final Thought

Rate limiting looks like arithmetic:

TEXT
count requests
compare with threshold
reject excess

Once the API is distributed, the interesting questions move elsewhere.

Where does the state live?

How current does it need to be?

How global is the quota?

Who owns the clock?

How much burst is acceptable?

How much inconsistency is tolerable?

What happens during Redis failover?

Can a hot tenant overload the limiter itself?

Can a replica return stale state?

Should login protection behave the same way as a free-tier quota?

Should an expensive endpoint be limited by requests per second or by concurrent work?

What happens when the mechanism whose job is to preserve availability becomes unavailable itself?

Those questions reveal what a rate limiter actually is.

It is not merely a counter.

It is an admission-control policy implemented by a distributed system.

Once we see it that way, perfect enforcement stops being the obvious goal.

For a security boundary, strong enforcement may justify sacrificing availability.

For API fairness, small approximation may be harmless.

For database protection, concurrency may matter more than request rate.

For multi-region systems, local independence may be more valuable than perfectly synchronized counters.

So the rate-limiting question I care about most is not:

“Which algorithm is most accurate?”

It is:

“What is the cost when this limiter is wrong—and what is the cost when the limiter itself is unavailable?”

Once those two costs are explicit, the architecture usually becomes much easier to choose.

References

  • Stripe Engineering — Scaling your API with rate limiters. Describes production use of request-rate limiters, concurrent-request limiters, fleet-level shedding, token buckets, and Redis, including the importance of making limiter middleware resilient when Redis or the limiter itself fails.

  • GitHub Engineering — How we scaled the GitHub API with a sharded, replicated rate limiter in Redis. Describes moving API rate-limit state into sharded and replicated Redis, using Lua for atomic operations, handling stale replica reads, and keeping rate-limit metadata consistent with the decision that produced it.

  • Cloudflare — How we built rate limiting capable of scaling to millions of domains. Discusses the tradeoff between purely local counters and globally centralized state, and describes an approximate sliding-window design evaluated across hundreds of millions of requests.

  • Redis Documentation — Redis rate limiter. Describes Redis as shared rate-limit state across service instances and covers atomic operations for fixed windows, sliding windows, and token buckets.

  • Redis Documentation — INCR rate-limiter pattern. Documents fixed-window implementations, expiration races, transactions, and Lua-based atomic updates.

  • Redis Documentation — INCREX. Documents the atomic increment primitive that can combine numeric updates, bounds, and expiration behavior in a single operation.