Every API needs rate limiting, and most implementations are a counter that resets every minute — easy to write and easy to defeat. Real rate limiting is about choosing the right algorithm for the abuse you are actually facing, keying it on something an attacker cannot trivially change, and layering it so one control failing does not leave you exposed. The gap between a counter and a defense is where most abuse gets through.
The naive counter and its burst problem
The obvious approach — count requests per user per minute, reset at the top of each minute — is called a fixed window, and it has a specific weakness that attackers exploit. Because the window resets on a clock boundary, an attacker can send a full window’s worth of requests in the last second of one minute and another full window in the first second of the next. Your “100 per minute” limit just allowed 200 requests in about two seconds, right at the boundary.
For casual limiting this may not matter. For anything protecting against a determined attacker — a login endpoint, an expensive operation — that burst is exactly the hole they will drive through. The fix is an algorithm that does not have a hard reset boundary to exploit.
Token bucket and sliding window: the two that matter
Two algorithms solve the burst problem, and each suits a different goal.
The token bucket models a bucket that refills at a steady rate — say, ten tokens per second — up to a maximum. Each request spends a token; when the bucket is empty, requests are refused until it refills. Its useful property is that it allows short bursts up to the bucket size while enforcing a steady average rate over time. That matches how legitimate traffic actually behaves: bursty in the moment, bounded on average. It is the right default for general API rate limiting, because it does not punish a user for a brief flurry of legitimate activity while still capping sustained load.
On each request:
refill: tokens = min(capacity, tokens + rate * elapsed)
if tokens >= 1: tokens -= 1; allow
else: reject (429)
The sliding window counts requests over the trailing N seconds continuously, rather than in fixed blocks, so it has no reset boundary to game — the 200-in-two-seconds trick simply does not work. It is stricter and slightly more expensive to compute, and it is the better choice when you want a hard, evenly-enforced ceiling with no bursts allowed, such as on a sensitive endpoint. Between them, token bucket for general use and sliding window for strict limits cover almost every real need.
What you key on is more important than the algorithm
The subtler and more consequential decision is what identity you count against — and limiting by IP address, the reflexive choice, fails in both directions at once.
It is too weak against attackers, because IP addresses are cheap. A determined attacker rotates through thousands of them from a botnet or a cloud provider, so a per-IP limit barely slows a distributed brute-force attempt — each address stays comfortably under the threshold while the aggregate hammers you. And it is too harsh on legitimate users, because many people share one IP: an entire office, a university, a mobile carrier’s NAT can all appear as a single address, so a per-IP limit meant for one abuser throttles hundreds of innocent users behind the same gateway.
The lesson is to key on the most meaningful identity available for the context. For an authenticated API, limit per user account, which is stable and expensive for an attacker to multiply. For a login endpoint — where there is no authenticated user yet — limit per targeted account rather than per source: track failed attempts against the username being attacked, so an attacker spreading a brute-force across a thousand IPs still trips a single account’s limit. That reframing, from “requests per source” to “attempts per target”, is what actually stops credential-stuffing, and it is the single most important idea here.
Login endpoints need their own thinking
Authentication is where rate limiting earns its keep, and it deserves defenses beyond a flat counter. Progressive delays — each failed attempt on an account waits a little longer before it is allowed to try again — turn brute-force from a fast operation into an impractically slow one, without ever locking a legitimate user out entirely. Combine that with per-account attempt tracking so the delay follows the targeted account across whatever source addresses the attacker uses.
Be careful with hard account lockouts, though, because they hand attackers a denial-of-service tool: if ten failed attempts lock an account, an attacker can lock every user out of your system on purpose by deliberately failing their logins. Progressive delays and requiring an additional factor after repeated failures are usually better than a flat lockout that can be weaponised against the very users it is meant to protect. And the strongest answer is to make stolen passwords useless in the first place — phishing-resistant credentials, as in our passkeys guide, remove the brute-force target entirely.
The shared-counter problem when you have more than one server
Everything above assumes a single place that counts requests. The moment you run more than one server instance — which is almost always — that assumption breaks, and it breaks in a way that quietly lets attackers through. If each of five instances keeps its own in-memory counter, an attacker gets five times the intended limit simply by having their requests spread across instances by the load balancer. The limit you configured is silently multiplied by your instance count.
So distributed rate limiting needs a shared counter that all instances consult, and Redis is the usual home for it because it is fast and central. But a shared counter introduces a subtlety of its own: the check-and-increment must be atomic. If an instance reads the count, decides the request is under the limit, and then increments — as three separate steps — two instances can both read the same under-limit value at the same instant and both allow a request that should have taken the count over. The race lets requests slip past exactly when concurrency is highest, which is precisely when a limit matters most.
# The check and the increment must be one atomic operation.
# A Redis Lua script (or INCR with an expiry) does it in a single round trip,
# so two instances cannot both read an under-limit value and both allow.
INCR key # atomic; returns the new count
EXPIRE key window # set once, on first increment
The standard fix is to do the whole operation atomically on the Redis side — a small Lua script, or the token-bucket logic expressed so the read and the write happen as one indivisible step. Then no two instances can interleave, and the limit holds no matter how many servers you run or how concurrent the traffic is. This is the same lost-update hazard that concurrent database writes have, solved the same way: make the read-modify-write a single atomic operation rather than three racing steps.
There is a cost worth acknowledging: every rate-limit check now makes a network call to Redis, which adds latency to every request and makes Redis a dependency your limiter cannot work without. For a hard security limit that is a fair trade. For a soft, generous limit, some systems accept slightly fuzzy enforcement — approximate counting that tolerates a little slack in exchange for not calling Redis on every request. Which you choose depends on whether the limit is protecting against abuse, where precision matters, or merely smoothing load, where it does not.
Layering, and telling the client the truth
Rate limiting works best in layers, because a single control is a single point of bypass. A limit at the edge or CDN absorbs volumetric floods before they reach your servers; a limit at the application enforces per-user and per-endpoint rules the edge cannot see; and expensive individual operations can carry their own specific limits. An attacker who finds a way around one layer still meets the next, which is the same defense-in-depth logic that governs which issues to fix first in our vulnerability prioritization guide.
Finally, be honest with well-behaved clients. When you rate-limit a request, return 429 Too Many Requests with a Retry-After header telling the client how long to wait, and expose the limit and remaining count in response headers so clients can self-regulate. A legitimate client that knows the rules will obey them; a hidden limit just produces confused, retrying clients that make your load worse — the retry-storm dynamic from our retry storms guide, triggered by your own opacity. Rate limiting is a conversation with clients as much as a wall against attackers, and the good ones will cooperate if you tell them how.