Rate Limiting, Actually Explained

Every engineer can sketch a rate limiter in thirty seconds: keep a counter, block once it's full. That answer is correct and almost useless — the entire difficulty is somewhere the thirty-second version doesn't look. Run the three simulators below and watch it break, and get fixed, for yourself.

Rahul Bisht·
Jul 2, 2026
·System Design·
1 min read
Interactive Guide

Three live simulators — watch a limit multiply, break, and hold

01 · Ground Zero

What Even Is a Rate Limiter?

A rate limiter is a gatekeeper: it looks at how many requests a client has already made recently, and decides whether this one gets through. That's the entire idea — a counter and a comparison. If you understand that sentence, you understand rate limiting conceptually.

Everything below this point is about keeping that gatekeeper correct and fast once there isn't just one gatekeeper — there are thousands of them, running on different machines, all supposedly enforcing the same rule for the same client at the same time.

02 · Scope It First

What Are We Actually Building?

Before picking an algorithm, it's worth being explicit about what this system is actually on the hook for — the same way you'd scope it out loud in an interview before drawing a single box.

It has to

  • Enforce a limit like “100 requests per user per minute,” per endpoint
  • Support different limits per client tier (free vs. paid) and per endpoint
  • Reject over-limit requests with a clear 429 and a Retry-After hint
  • Let the platform team change limits without an engineering deploy

It's explicitly not on the hook for

  • Issuing or authenticating API keys — a client_id is assumed to already exist
  • Billing or usage metering
  • Network-edge DDoS protection — a different problem, below the application layer

One constraint shapes everything downstream: this sits on the hot path of every request, so it has to add close to zero latency and keep working even when part of its own infrastructure is degraded. That constraint is why this guide keeps coming back to “check less often” and “fail open vs. fail closed” instead of just “add more Redis.”

03 · Size It

Sizing the Problem

Numbers here are explicit back-of-envelope estimates, not measured production values — but they're what determines whether “just use Redis” actually holds up, and why this guide keeps coming back to checking less often instead of scaling Redis harder.

Traffic

On the order of 1M requests/second, platform-wide, at scale — the number every design decision downstream has to survive.

Live counters, not one per request

Keys are roughly active_clients × active_endpoints. For 50M active clients against ~3 endpoints each, that's on the order of 150 million live keys at any moment.

Storage

Each key is a small counter with a TTL equal to the window length, so old windows expire on their own — a few tens of GB total, comfortably shardable across a modest Redis cluster.

The constraint that actually rules the design

This is memory- and ops-bound, not storage-bound — 1M checks/second against one shared store is the number that rules out checking Redis on every single request without something smarter in front of it.
04 · The Whole Picture

The Shape of the System

Before drilling into any one piece, here's the system end-to-end: a request hits the gateway, gets a fast local check, and only pays for a strict Redis round trip when it's actually close to its limit. The API contract and data model below are what every later section refines — nothing past this point replaces this picture, it only explains why each piece of it exists.

request
under limit
near limit
check + incr
policy
Client
API Gatewaylocal check
Backend Service
Rate LimiterLua check+incr
Redis Clustersharded
Policy Store

Local approximate check at the edge, strict Redis check only when it's close — hover a node

// called by the API gateway, in-process or over a fast internal call
POST /internal/ratelimit/check
{ client_id, endpoint, cost: 1 }
→ { allowed: true, remaining: 42, reset_at: "2026-07-28T10:01:00Z" }
PUT /admin/policies/{endpoint}
{ scope: "per_user" | "per_ip" | "per_key", limit: 100, window_seconds: 60, tier: "free" | "paid" }

What the client actually sees is usually headers, not a body a human reads — the same shape Stripe, GitHub, and most public APIs use, so an HTTP client can back off automatically without parsing anything custom:

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785315660
Retry-After: 42

Client

Whoever is being limited: a user, an API key, or an IP, depending on scope.

Endpoint

The route a policy applies to — different endpoints reasonably get different limits.

RateLimitPolicy

The rule itself: scope, max requests, window length, which tier it applies to.

Counter

The live count for one client against one policy in the current window.
accumulates
has
governs
Client
Endpoint
RateLimitPolicy
Counter

Counter keyed by {client_id}:{endpoint}:{window_start} — old windows expire off a TTL, no cleanup job needed

RateLimitPolicy is deliberately a separate entity from Counter so the platform team can change a limit without touching, resetting, or migrating any in-flight counts.

05 · Deep Dive: The Broken Version

Start With the Broken Version

Naive
Every server that handles requests keeps its own counter in memory. A request comes in, the counter goes up, and once it crosses the limit, that server says no. It costs nothing extra to run, and it's almost always the first thing people reach for.
Breaks
The moment there's more than one server. A load balancer spreads a client's requests across all of them, and none of those servers can see what the others are counting. Turn the server count up below and watch a “5 requests per window” rule quietly turn into 20.
Fix
Put the counter somewhere every server can actually see — the subject of the next section.
Try it
Servers behind the load balancer
Load Balancer
Server 1
its own counter
0/5
Intended limit: 5/windowEffective limit with 1 server: 5/window
06 · Deep Dive: The First Fix

Move the Counter Somewhere Everyone Can See It

The fix is to stop counting locally and count in one shared place instead — usually Redis. Every request does an atomic increment against a key like ratelimit:{client_id}:{endpoint}:{window} and compares the result to the policy's limit.

That fixes correctness — there's exactly one count now, not one per server. It also creates two new problems. Every request now pays a network round trip to Redis before it can proceed, which eats into a latency budget that's supposed to be close to zero. And Redis is now a dependency the whole platform shares, so a busy client — or a busy shard — can slow things down for everyone else hitting that same node. Getting the count right was the easy part.

07 · Deep Dive: Making It Fast

Check Less Often, Check Atomically When It Matters

Two separate moves get this to production scale, and they answer two different questions — refining the architecture already shown above, not replacing it.

How the check happens: run the check-and-increment as a single Lua script executed inside Redis — one round trip instead of a read followed by a conditional write. That closes a real race where two concurrent requests could both read “under limit” before either one increments. Shard the Redis layer by client_id so no single node absorbs the whole platform's traffic.

How often it has to happen: most requests, for most clients, aren't anywhere near their limit. Keep a small local approximation at the edge — an in-memory count, reconciled against Redis periodically — and only fall through to a strict, synchronous Redis check when a client is getting close to their actual limit. The common case becomes a local, sub-millisecond check; the network round trip is reserved for the case that actually needs precision.

At the ~1M req/s scale sized earlier, if most clients aren't near their limit, this split keeps the volume of strict Redis checks to a small fraction of that total — the local check absorbs the rest.

08 · Deep Dive: The Real Decision

The Algorithm Is the Actual Decision

Once the counter lives somewhere shared and the checks are fast, one real design decision is left: how do you decide “over the limit”? Three common answers behave differently in a way that matters. Pick one below, pick a traffic pattern, and press run.

Try it

Counter resets to zero on the clock, every 3s.

5 requests right before the window resets, 5 more right after.

Window 1
Window 2
Window 3
Window 10/5
Window 20/5
Window 30/5
0 allowed0 rejected

Fixed Window

Resets to zero on the clock, every window.

+ Simplest to build — one counter, one TTL

+ Cheapest to run at scale

– Lets ~2x the limit through in a burst around the reset, as you just saw

Sliding Window Counter

Blends in a fraction of the previous window instead of forgetting it instantly.

+ Fixes the boundary bug without per-request timestamps

+ Same cheap two-counter footprint as fixed window

– Still an approximation, not exact enforcement

Token Bucket

A bucket of tokens that refills continuously — no calendar windows at all.

+ A saved-up allowance can burst instantly, then throttles to steady rate

+ Behavior doesn't depend on wall-clock alignment

– Wrong call if bursts are actually undesirable

Fixed window's boundary bug isn't something a single-server test would ever catch — it only shows up once real traffic clusters around a reset, which is exactly what the “burst at the boundary” pattern above just showed you.

09 · Deep Dive: The Second-Order Bug

The Same Bug, From the Client's Side

Naive
Reject requests once they're over the limit, and let the client retry later.
Breaks
If a burst of requests gets rejected right up until the window resets, those same clients often retry at nearly the same instant — because they're all waiting on the same clock. That produces a spike synchronized to the wall clock, on top of whatever the servers were already handling.
Fix
A sliding window counter smooths this out structurally, since there's no single instant where the count resets to zero. On top of the algorithm choice, adding a small amount of random jitter to the Retry-After value stops every rejected client from retrying at exactly the same moment.
Tradeoff
Jitter adds a small amount of unpredictability to when a client's request actually lands — an acceptable trade against every rejected client retrying in the same instant.
Try it

20 rejected clients retry the instant each window resets, all at once.

requests per 200ms tick, over 3 windows — amber line marks each window reset

Peak in one tick: 1 requests40 total retries across 2 window resets
10 · Deep Dive: One Request, Multiple Rules

One Request, More Than One Rule

In practice a single request is usually checked against more than one policy at once — a per-user quota and a coarser per-IP abuse limit, say. The cheap move: check whichever policy is most likely to reject first, usually the tightest, most abuse-oriented one, so a request that's going to be rejected anyway fails fast — before paying for a Redis round trip against every applicable policy.

11 · Operate It: When It Breaks

What Breaks, and How It Degrades

Redis shard unreachable

Impact: Requests against that shard's clients can't be strictly checkedResponse: Explicit, endpoint-dependent choice: fail open (allow through, log for later review) for low-risk endpoints, fail closed for expensive or abuse-prone ones like login

Policy store unreachable

Impact: Rate limiter can't fetch a new or updated policyResponse: The rate limiter caches the last-known-good policy set locally, refreshed periodically — a brief outage delays policy changes, it doesn't stop limiting from working

One Redis node overloaded by a hot client

Impact: Latency degrades for every other client sharing that nodeResponse: Sharding by client_id bounds the blast radius to that shard; a sufficiently large client may need its own dedicated shard

Rate limiter service instance crash

Impact: In-flight local-approximation state for that instance is lostResponse: Stateless design — the instance restarts and re-syncs its local approximation from Redis; no client-visible correctness issue since Redis stays the source of truth

Redis itself runs with replicas per shard, so a single node failure triggers a fast replica promotion rather than a shard-wide outage — acceptable here because a counter that's a few seconds stale after failover is still well within the tolerance of a system that's already an approximate, soft limit at the margin.

12 · Operate It: Watching It

Watching It in Production

Rate-limiter check latency (P99)

Why it matters: Sits on every request's hot pathAlert on: Sustained P99 above a few milliseconds

Reject rate, overall and per-endpoint

Why it matters: A sudden spike often means a client bug (retry loop), not real abuseAlert on: Sharp spike vs. 24h baseline for one client/endpoint

Redis shard hot-keying

Why it matters: One oversized client can degrade the shard everyone else sharesAlert on: Any single shard's ops/sec far above the cluster average

Local-cache vs. Redis check ratio

Why it matters: Confirms the approximate local check is actually absorbing most trafficAlert on: Ratio drops sharply — most requests are now near their limit

The limiter only works if client_id can't be trivially spoofed — if it's derived from a header the caller controls rather than an authenticated identity or a properly-derived IP, a rate limit is just a suggestion. Sharding by client_id isn't only a performance optimization, either — it's what stops one noisy tenant from degrading Redis latency for every other tenant sharing the cluster.

13 · Judging an Answer

How Deep Should Your Answer Go?

Mid-level

Identifies that per-server in-memory counters break under load balancing, and lands on a shared Redis counter with INCR and a TTL.

Senior

Independently raises the fixed-window boundary problem and picks sliding window or token bucket accordingly; reasons about Redis as a shared bottleneck and proposes sharding by client; treats the atomic check-and-increment — not just "use Redis" — as the actual correctness requirement.

Staff+

Pushes on the latency budget itself — proposes reducing Redis round trips via local approximation rather than just scaling Redis harder; treats fail-open vs. fail-closed as an explicit, endpoint-dependent product decision; reasons about cross-region deployments, where strict global consistency on a counter usually isn't worth the latency cost given the limit is inherently a soft target at the margin.
14 · Go Deeper

Further Reading

Related posts

View all →