Why “100 requests per minute” is not a rate-limiting strategy

Ronnie Kiyegga
Ronnie Kiyegga

When one route starts eating more than its share of capacity, such as an export endpoint that an integration is polling all day, the quickest protection is a number: limit requests to 100 per minute.

It’s easy to add to middleware, and it gives clients a clear 429 Too Many Requests response. But it immediately raises harder questions. Is the limit per IP address, per user or per organisation? Should short bursts be allowed? Should an export cost the same as a cached read? And should a payment webhook be rejected because unrelated traffic used up the budget first?

Until you know which resource needs protecting and whose requests should share it, 100 requests per minute is just a counter with a threshold.

In this article, I’ll turn that number into an actual strategy: what each limit protects, who it applies to, how bursts and refusals should work and what happens when the limiter itself fails.

One count for very different work

A single global cap still has value: it can reduce obvious accidental overload, protect a small service from one poorly behaved client and set a basic boundary before more detailed policies exist. Its weakness is that it counts every request as the same amount of work.

GET /healthvery cheap
GET /applicationsdatabase query
POST /auth/loginsecurity-sensitive and potentially expensive
POST /exportspotentially high CPU and database work
POST /checkoutpayment-provider interaction and business risk
POST /webhooks/stripeexternally retried delivery that must be handled carefully

A single global count treats all of these operations as equivalent. The cheap route can consume the same budget as the expensive one, a user may be blocked from a core action while the endpoint causing the pressure remains reachable, and a legitimate integration may be throttled because unrelated traffic shares its identity.

What each route is protecting

A rate limit should begin with the resource or failure mode being protected. For example:

Login endpoint
Protect against credential stuffing and identity-provider overload.
Export endpoint
Protect database and file-generation capacity.
Search endpoint
Protect an expensive query path.
Public API
Protect shared platform capacity and provide a fair customer quota.
Webhook endpoint
Protect processing capacity while preserving safe retry behaviour.

The policy follows from that answer. An expensive report route may need a small per-user or per-organisation limit; a read-heavy route may need caching and a higher allowance; a login route may use an IP-level policy before authentication and a user-level policy after it. The point is not to create a unique algorithm for every endpoint, only to stop treating every request as the same kind of work.

Identity determines whether the policy is fair

Every rate limiter needs a key: the identity used to group requests. Common options include:

IP address
Authenticated user ID
API key
Organisation or tenant ID
Session ID
A combination of signals

Each has limitations. IP address can be useful for anonymous abuse, but it can unfairly group users on shared Wi-Fi, mobile networks, offices, universities or schools. User ID is more meaningful after authentication, but it does not protect a public login endpoint. API keys work well for integrations when they are scoped and revocable, but they cannot protect a browser route where no key exists. A more realistic policy is layered:

Anonymous routes:
limit by IP and endpoint type
Authenticated routes:
limit by user or workspace
API integrations:
limit by API key and contract/plan
Expensive operations:
apply a lower separate budget

There is rarely a perfect key. Fairness here has little to do with mathematical evenness: a policy is fair when it stops one actor from consuming a shared resource in a way that harms others, without punishing unrelated users who happen to share an identity.

Fixed windows look correct until the boundary

A fixed-window counter is straightforward:

100 requests between 12:00:00 and 12:00:59

The boundary creates a familiar issue: a client can send 100 requests at the end of one minute and another 100 immediately at the beginning of the next. The policy technically allows both, but the protected system experiences a concentrated burst. That may be fine for a low-risk endpoint, but not for a route that triggers expensive work. Different approaches create different behaviour:

Fixed window
Simple and cheap, but allows boundary bursts.
Sliding window counter
Smoother approximation of recent activity.
Sliding log
More precise, but higher storage and processing cost.
Token bucket
Allows controlled bursts while enforcing an average rate.
Leaky bucket
Smooths work toward a steady output rate.

The choice depends less on which algorithm is most advanced than on two policy questions: should this client be allowed to burst, and how quickly must the system recover afterwards? If a dependency can only process work steadily, a queue or leaky-bucket-like policy may fit. If a client should be able to make a small burst of valid requests, a token bucket states that policy directly.

Token buckets make the burst policy explicit

A token bucket starts with a defined capacity, and tokens are added back at a steady rate. Each request costs one or more tokens; if enough tokens are available, the request proceeds, and if not, it is rejected or delayed.

Bucket capacity: 20 tokens
Refill rate: 5 tokens per second
Request cost: 1 token

A client can make a short burst of up to 20 requests. Over time, the average is constrained to five requests per second. That burst allowance is useful for traffic that is legitimate but uneven:

User loads a dashboardseveral parallel requests are normal
Client reconnects after a short network interruptiona small recovery burst is reasonable
Integration sends a bounded batchshort-term capacity is useful

Unlike a fixed window, the allowance does not reset in full when the minute ends: it permits a limited burst while keeping sustained demand within a defined rate.

Not every request should cost one token

Because a request can cost more than one token, the bucket can also reflect how expensive the work is:

Read cached profilecost 1
Search across a large datasetcost 3
Generate exportcost 10
Create expensive reportcost 20

This does not mean every route needs a complicated scoring system. It means a cheap health check and an expensive export should not have equal claims on capacity. The same idea applies to whole classes of work, which can draw from separate budgets:

Interactive user actionsprotected budget
Background jobslower-priority budget
Bulk exportsseparate queued budget
Administrative operationsconstrained but recoverable budget

This is where rate limiting connects with product priorities: when the system is busy, which user actions must remain possible?

A local counter is not a distributed policy

An in-memory counter can work in a single development process, but it becomes unreliable when traffic is distributed:

Instance A allows 100 requests
Instance B allows 100 requests
Instance C allows 100 requests

A client may receive 300 requests of effective capacity while the intended limit was 100. That does not mean every project needs Redis immediately. A shared store, API gateway, CDN policy, managed platform feature or dedicated rate-limiting service earns its place when distributed enforcement is a real requirement. Redis is a common option because atomic counters with expiry are useful, but adding it solely to satisfy a system-design checklist is not evidence of good architecture. The system should use the simplest enforcement point that reliably protects the resource.

A shared store also puts a new dependency in front of every request it guards, which raises a question the local counter never had to answer: what happens when the limiter itself is slow or unavailable? Failing closed refuses traffic because enforcement cannot be trusted. Failing open admits it unchecked. Neither is right everywhere, and the choice follows from what the route is protecting. A login endpoint that exists to slow credential stuffing may be safer refusing requests than admitting them without a check. A cached read route limited mainly for fairness may be better off staying available through a short period without enforcement.

Either way, the limiter's own latency and errors need to be visible. Otherwise a limiter that adds delay to every request, or fails open unnoticed, has changed the policy without anyone deciding to.

Reject, queue or delay

A rate limit is a refusal, and a `429` should not feel like a broken product. A client needs enough information to recover. `Retry-After` can be expressed as a delay in seconds, so a client that must wait 42 minutes can receive:

HTTP/1.1 429 Too Many RequestsRetry-After: 2520

A user-facing interface should explain that the action did not complete and when it can be retried, without exposing unnecessary internal detail:

You have reached the export limit for this hour.
Try again in 42 minutes, or narrow the report before exporting.

Rejection is not the only option. For work that can finish later, a queue may be more appropriate:

Your report is being prepared.
We will notify you when it is ready.

That only works when delay preserves the meaning of the workflow. A long report can reasonably become an asynchronous job; a booking confirmation cannot silently enter an unbounded queue.

Rate limits and retries can fight each other

A badly designed client can turn a rate limit into additional pressure:

Request receives 429
Client retries immediately
Another 429
More rejected work

The client should respect `Retry-After`, apply backoff and jitter, and have a bounded retry budget. Mutations need extra care: if a client times out around the point a request is processed, it may not know whether the operation happened, and retrying blindly can create a duplicate effect.

Rate limitingprotects finite capacity
Idempotencymakes repeated mutation attempts safe

They solve different problems and need to work together.

Observe who is being limited

A rate limiter that only emits a `429` is difficult to improve. I want to know:

  • Which route is rejecting requests?
  • Which identity class is affected?
  • Are requests legitimate, abusive, accidental, or caused by a broken client?
  • Which limits are protecting a real resource?
  • Are clients respecting the recovery instruction?
  • Are retries creating more rejected traffic?
  • Is one customer consuming disproportionate expensive work?

Avoid logging raw personal identifiers unnecessarily; aggregated metrics and safe identifiers are often enough. It also helps to keep rejection reasons distinct:

Rejected because a rate policy was reached
Rejected because authentication failed
Rejected because authorisation failed
Failed because a dependency was unavailable

These may look similar to a user, but they need different operational responses.

Before choosing the number

“100 requests per minute” is not a strategy until it answers:

Which resource are we protecting?
Which endpoint or operation creates the cost?
Whose requests are grouped together?
Are short bursts acceptable?
How is the policy enforced across instances?
What does recovery look like?
What do we measure?

A rate limiter is an admission-control policy. It does not create downstream capacity. It decides how the capacity that already exists is shared, so that legitimate users keep the service when demand, bugs or abuse would otherwise consume it. The best limit is rarely the strictest one. It protects the requests that matter without letting one source of traffic consume the capacity everyone else needs.

Other articles