API Rate Limiting: Designing Limits That Protect Without Choking Clients

Rate limiting caps requests per client in a time window, protecting your server from abuse and cost without choking legitimate users. Start at 60 requests per minute for anonymous and 600 for authenticated, and return a 429 with Retry-After.
Rate limiting is capping the number of requests an API accepts from each client within a time window, meant to protect your server from abuse, attacks, and rising cost without choking legitimate users. A practical starting rule: 60 requests per minute for anonymous (unauthenticated) requests and 600 per minute for an authenticated user, raising the limit for certain heavy operations or lowering it for sensitive endpoints like login. When the limit is exceeded, the server replies with 429 Too Many Requests and a Retry-After header telling the client how many seconds to wait before the next attempt.
The two best-known algorithms: Fixed Window counts requests in a set period then resets the counter, simple but allowing a double burst at the boundary of two windows. And Token Bucket gives the client a balance of tokens spent on each request and refilled at a steady rate, allowing short bursts while controlling the average rate, which is the fairest for most cases. Implementation works on two layers: Nginx as a first line of defense before the request reaches your app, and Laravel throttle for fine limits per user and route. Most importantly, the limit protects your server cost: every request consumes CPU and memory, and the limit stops one client from swallowing everyone's resources. We break down the algorithms, the numbers, and the implementation.
Fixed Window vs Token Bucket
Understanding the difference decides the quality of your protection. Compare the two algorithms on a limit of 60 requests per minute:
| Criterion | Fixed Window | Token Bucket |
|---|---|---|
| Idea | Counter resetting each minute | Token balance refilling at a steady rate |
| Handling bursts | Blocks them abruptly at the ceiling | Allows a short burst within the balance |
| Boundary loophole | Allows 120 requests around the reset moment | None, the average rate stays fixed |
| Simplicity | Simpler to implement | Slightly more complex |
| Best for | Simple general protection | A real API needing fairness |
The boundary loophole in Fixed Window matters: if a client sends 60 requests in the last second of a minute, then 60 in the first second of the next, it pushes 120 requests through in two seconds even though the limit is 60 per minute. Token Bucket closes this loophole because the balance refills gradually rather than all at once, keeping the average rate controlled. For most serious APIs, Token Bucket or one of its derivatives (Sliding Window) is the fairest choice.
Practical starting numbers
Limits are not a single number, they are layers by request type and sensitivity:
- Anonymous requests: 60 per minute per IP. Enough for normal browsing, a barrier to aggressive crawling.
- Authenticated user: 600 per minute per account. A real user rarely approaches this, but it stops a compromised account from flooding the server.
- Sensitive endpoints (login, password reset, OTP): 5 attempts per minute per IP and account. This stops password-guessing attacks.
- Heavy operations (export, reports, large uploads): a lower limit like 10 per minute since they consume far more resources.
Watch your real clients' patterns for a week, then tune. A good limit lets 99% of legitimate use pass without friction, and only the abuser hits it. Start with these numbers and move up or down based on data, not guesswork.
429 headers and Retry-After
Rejecting the request is not enough, you must tell the client clearly. The correct response when the limit is exceeded is a 429 Too Many Requests code with headers explaining the state:
- Retry-After: seconds before the next attempt, so the client knows when to return instead of retrying immediately and doubling the load.
- X-RateLimit-Limit: the maximum in the window.
- X-RateLimit-Remaining: what is left of the balance.
- X-RateLimit-Reset: when the balance refills.
These headers turn the limit from an opaque wall into a clear contract: a good client respects Retry-After and slows its own rate automatically. Without these headers, clients retry aggressively on rejection, so the load worsens instead of easing.
Implementation via Nginx and Laravel
Protection on two layers is stronger than one, since each layer catches what the other misses.
Layer one: Nginx
Nginx limits requests before they even reach your app, protecting even against requests that would have consumed the cost of starting PHP. It uses limit_req_zone to define a limit zone at a set rate (say 60 per minute per IP) with a burst allowing a short spike then enforcing the rate. This is the cheapest protection because it stops abuse at the door before it costs you CPU. It is especially useful against aggressive crawling and simple denial attacks.
Layer two: Laravel throttle
Inside the app, Laravel's throttle middleware gives fine control per authenticated user and per route, like throttle:60,1 for sixty per minute, or custom limits per endpoint via RateLimiter. This layer understands user identity (not just IP), so it distinguishes an authenticated user from a visitor and tunes sensitive endpoints precisely. Laravel stores the counters in cache (Redis is ideal for speed), so limiting is fast with negligible overhead.
What to limit on: IP, user, or API key
A question more important than the number itself: what identifies the client? Each key has its use:
- IP address: the only one available for unauthenticated requests. Its weakness is that users behind one network (an office, a university, a mobile carrier) share an IP and hit the limit together, so keep the IP limit relatively generous.
- User identity: the most accurate for authenticated requests, giving each account its own limit independent of its network location.
- API key: best for developer APIs, tying the limit to the key and granting different keys different limits by plan.
The best approach combines them: a loose IP limit as a first line of defense against anonymous clients, and a finer per-user or per-key limit for authenticated ones. This way an anonymous abuser does not get a legitimate user blocked, and a compromised account does not escape accountability.
Storing counters: why Redis
A limit needs a counter read and written on every request, so where you store it decides its speed and accuracy. Storing it in the database is slow and adds load to every request. The practical choice is Redis, in-memory and very fast, supporting atomic operations (INCR with an expiry) that count and reset precisely without a race between concurrent requests. Most importantly, Redis unifies the counter across several processes or servers: if your app is distributed, a per-process in-memory counter lets a client hit the limit several times over, while a central Redis enforces one true limit on all clients. This is why Laravel stores throttle counters in cache, and setting it to Redis makes limiting fast and accurate at once.
Protecting server cost
Rate limiting is not only security, it is cost control. Every request consumes CPU, memory, and bandwidth, and on a plan with fixed resources a single abusive client or a runaway script can swallow the whole server's capacity and starve the rest. The limit ensures fair resource distribution and guards against surprise bandwidth bills, since adding 1 TB of data transfer costs 1,150 SAR per year, and a correct limit prevents draining it on pointless crawling. On a cloud server whose resources you manage yourself, rate limiting is the safety valve between a stable service and one collapsing under a single runaway load.
The practical decision
- Start with 60 per minute for anonymous, 600 for authenticated, and 5 for sensitive endpoints.
- Use Token Bucket or Sliding Window to avoid the Fixed Window boundary loophole.
- Put layer one in Nginx via limit_req_zone to stop abuse at the door.
- Add layer two via Laravel throttle for fine limits per user and route.
- Always return 429 with Retry-After and RateLimit headers so the client cooperates.
- Watch real rejections for a week and tune the numbers on data, not guesswork.
A good limit is never felt by a legitimate user, and only the abuser runs into it. Design it from your usage data, and use it to protect your cost and your stability at once.


