Skip to content

Rate Limiting and Throttling

Every service has a ceiling. A given instance can serve only so many requests per second before its CPU saturates, its connection pool fills, or its memory climbs. Most of the time you run comfortably below that ceiling. But traffic is not smooth: a marketing email goes out, a popular client deploys a tight retry loop, a scraper discovers your API, or an upstream outage suddenly redirects a flood of requests your way.

The previous patterns in this module protect you from your dependencies failing. Rate limiting protects you from your callers — and from your own success.

When demand exceeds capacity, a service does not fail gracefully on its own. It tips over. As requests arrive faster than they complete, queues grow, latency climbs, timeouts fire, and clients retry — adding more load to an already overloaded service. Past the tipping point, throughput does not just plateau; it collapses, because the server spends its time managing a backlog it can never clear. A service that could comfortably serve 1,000 requests per second can end up serving zero under a flood of 5,000, because every request times out before it completes.

So the forces are: you have a finite capacity, demand can spike unpredictably above it, and serving everything badly is worse than serving most things well — you need to keep the service operating inside its safe envelope even when more work arrives than it can handle.

Rate limiting caps how much work a service accepts over time, holding it inside its safe envelope. When requests arrive faster than the configured limit, the excess is either shed (rejected immediately, conventionally with HTTP 429 Too Many Requests) or briefly queued to smooth out short bursts. Either way, the work that is admitted is served well.

The most common mechanism is the token bucket. A bucket holds up to capacity tokens and refills at a steady rate of refillPerSecond tokens. Each request must take one token to proceed; if the bucket is empty, the request is rejected. Because the bucket can hold a reserve of tokens, it permits short bursts (spend the accumulated tokens) while still enforcing the long-run average rate (the refill rate). That combination — bursty in the small, bounded in the large — matches how real traffic actually behaves.

flowchart LR
  REF["Refill: +r tokens / second"] --> B
  subgraph B[Token bucket • capacity C]
    T[(tokens)]
  end
  REQ[Incoming request] --> CHK{token<br/>available?}
  B --> CHK
  CHK -- yes --> TAKE[take 1 token → allow]
  CHK -- no --> DROP[shed → HTTP 429]
Token bucket — steady refill allows a sustained rate; the stored capacity absorbs short bursts; empty means shed

Here is a token-bucket limiter. It lazily refills based on elapsed time, then admits a request only if a token is available. Each example is self-contained and idiomatic to its language.

class TokenBucket {
private tokens: number;
private last: number = Date.now();
constructor(
private readonly capacity: number,
private readonly refillPerSec: number,
) {
this.tokens = capacity;
}
private refill(): void {
const now = Date.now();
const elapsed = (now - this.last) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSec);
this.last = now;
}
tryAcquire(): boolean {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return true;
}
return false;
}
}
const limiter = new TokenBucket(100, 50); // burst 100, sustain 50/s
if (!limiter.tryAcquire()) {
return new Response('Too Many Requests', { status: 429 });
}

What you gain:

  • Protected capacity. The service never accepts more work than it can serve, so it degrades by rejecting some requests instead of failing all of them. Throughput stays on the plateau instead of collapsing past the tipping point.
  • Fairness and abuse control. Limiting per client (per API key, per IP) stops one noisy or malicious caller from starving everyone else, and gives you a lever against runaway retry loops and scrapers.
  • Burst tolerance. A token bucket absorbs short, legitimate spikes using its stored capacity while still bounding the sustained rate — you do not have to choose between “smooth only” and “no limit”.

What it costs you:

  • Choosing the limits. Set the rate too low and you reject traffic the service could have served; too high and the limiter does not protect you. The numbers come from load tests against real capacity, not guesses, and they drift as the service changes.
  • Returning the right signal. Rejected requests should return 429 Too Many Requests, ideally with a Retry-After header, so well-behaved clients back off instead of hammering. A bare error invites immediate retries that defeat the limiter.
  • Distributed coordination. A limiter in one instance only sees that instance’s traffic. Enforcing a global limit across many instances needs shared state (a central store such as Redis), which adds a dependency and some latency to the hot path.
  • Circuit Breaker — protects you from a failing dependency; rate limiting protects a service from its callers.
  • Bulkhead — caps concurrency where rate limiting caps throughput; the two compose well.
  • Retry and Timeout — a 429 with Retry-After tells a client’s retry logic exactly how long to back off.
What does rate limiting protect a service from?
How does a token bucket allow short bursts while still bounding the long-run rate?
What status code should a service return when it sheds a request due to rate limiting?
Why can throughput collapse when demand exceeds capacity without a rate limiter?