Skip to main content

🚦 Rate Limiting and Throttling

A public API is a shared resource, and without guardrails one aggressive client β€” or one runaway script β€” can starve everyone else. Rate limiting is how you keep traffic fair, costs predictable, and your servers upright under load. This lesson covers the four classic algorithms and how to deploy them for real.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why APIs need rate limiting and how it differs from throttling and quotas
  • Compare the fixed window, sliding window, token bucket, and leaky bucket algorithms and pick the right one
  • Implement a token bucket and an Express rate limiter from scratch
  • Choose the right granularity (IP, user, API key) and enforce limits across a cluster with Redis
  • Return the correct 429 status and headers so clients can back off gracefully

Estimated Time: 40–55 minutes  β€’  Difficulty: Advanced

Hands-on: Build a token-bucket limiter and prove it blocks a burst.

In This Lesson

Why Rate Limiting Matters

Rate limiting caps how many requests a client may make in a given time window. It sounds defensive, and it is β€” but it also protects the honest majority of your users from the noisy few.

πŸ’‘ A useful analogy: Picture a nightclub with a fixed capacity. The bouncer only lets so many people in per minute. Once it's full, newcomers wait. Nobody's night is ruined by overcrowding, and the fire marshal stays happy. A rate limiter is that bouncer for your API.

Without a limiter, an API is exposed to several failure modes at once:

  • Denial of service β€” a flood of requests exhausts CPU, memory, or database connections.
  • The "noisy neighbor" β€” one heavy client degrades latency for everyone sharing the service.
  • Runaway cost β€” usage-billed downstreams (databases, third-party APIs, LLMs) can generate a shocking invoice.
  • Brute-force attacks β€” unlimited login attempts make credential-guessing trivial.
  • Cascading failure β€” an overloaded service takes its dependencies down with it.

Rate limiting turns each of these from a catastrophe into a polite 429 Too Many Requests.

Limiting, Throttling & Quotas

These three terms overlap in casual speech but describe distinct behaviors. Knowing the difference helps you design the right policy.

TermOver-limit behaviorTime scaleNightclub analogy
Rate limitingRejects excess requests (429)Seconds–hours"We're full β€” come back later"
ThrottlingDelays / queues requestsSeconds–hours"Wait in line, you'll get in"
QuotaCaps total volume, often billedDays–monthsYour monthly membership visits

Most production APIs combine all three: a short-window rate limit to smooth traffic, throttling to shape bursts, and a monthly quota tied to a pricing plan. For example, a plan might allow 100 requests/minute (rate limit) up to 100,000 requests/month (quota).

πŸ“– Key Terms

Burst: a short spike of requests well above the sustained average rate.

Window: the time interval over which requests are counted.

Backoff: a client waiting (ideally with increasing delay) before retrying after a 429.

The Four Core Algorithms

Every rate limiter is one of a handful of algorithms. Each trades off simplicity, memory, and how it handles bursts.

1. Fixed Window Counter

Divide time into fixed blocks (say, one minute) and count requests in the current block. Reset the counter when the block rolls over. Dead simple, but it has a famous flaw: a client can send a full window's worth of requests at 10:00:59 and another full window at 10:01:00 β€” double the intended rate across that boundary. This is the edge-spike problem.

2. Sliding Window Counter

Smooths the edge spike by blending the current and previous windows with a weight based on how far into the current window you are. Nearly as cheap as fixed window, but far more even. A great default for most APIs.

flowchart LR A[Request] --> B{Weighted count of
current + previous window} B -->|Under limit| C[Accept & increment] B -->|Over limit| D[Reject 429]

3. Token Bucket

A bucket holds up to N tokens and refills at a steady rate. Each request spends a token; if the bucket is empty, the request is rejected or delayed. The bucket's capacity allows short bursts (spend saved-up tokens) while the refill rate enforces the long-term average. This flexibility makes it the most widely used algorithm β€” it's what AWS API Gateway and Stripe use.

4. Leaky Bucket

Requests enter a queue and are processed ("leak out") at a fixed rate. Overflow is dropped. Unlike the token bucket, it smooths output completely β€” no bursts pass through β€” which is ideal when a downstream system needs a perfectly steady feed.

Token bucket versus leaky bucket The token bucket refills tokens that requests consume, allowing bursts. The leaky bucket queues requests and processes them at a fixed rate, smoothing output. Token Bucket ↓ tokens refill at fixed rate request spends a token empty β‡’ reject (bursts OK) Leaky Bucket ↓ requests queue up steady output rate (no bursts)
Figure 1 β€” The token bucket permits bursts up to its capacity; the leaky bucket enforces a perfectly even outflow.

πŸ’‘ Which one should I use?

Reach for the sliding window as a simple, fair default. Use the token bucket when you want to tolerate occasional bursts (most public APIs). Use the leaky bucket when a downstream must receive a smooth, constant stream. Plain fixed window is fine only for coarse, non-critical limits.

Building a Token Bucket

The token bucket is worth implementing yourself once β€” it clarifies the whole idea. The key trick is lazy refill: instead of a timer topping up tokens, we calculate how many tokens should have accrued based on elapsed time whenever a request arrives.

class TokenBucket {
  constructor(capacity, refillPerSecond) {
    this.capacity = capacity;         // max burst size
    this.refillRate = refillPerSecond; // sustained rate
    this.tokens = capacity;           // start full
    this.lastRefill = Date.now();
  }

  #refill() {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    // Add accrued tokens, capped at capacity
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
    this.lastRefill = now;
  }

  tryConsume(cost = 1) {
    this.#refill();
    if (this.tokens >= cost) {
      this.tokens -= cost;
      return true;   // allowed
    }
    return false;    // rate limited
  }

  // Seconds until enough tokens exist (for a Retry-After header)
  retryAfter(cost = 1) {
    this.#refill();
    if (this.tokens >= cost) return 0;
    return (cost - this.tokens) / this.refillRate;
  }
}

Wiring it into Express, keyed per client, gives a working limiter:

const buckets = new Map(); // key -> TokenBucket

function rateLimit(req, res, next) {
  const key = req.user?.id ?? req.ip;
  if (!buckets.has(key)) {
    buckets.set(key, new TokenBucket(10, 1)); // burst 10, 1 req/sec
  }
  const bucket = buckets.get(key);

  if (bucket.tryConsume()) return next();

  const wait = Math.ceil(bucket.retryAfter());
  res.set('Retry-After', String(wait));
  res.status(429).json({ error: 'Rate limit exceeded', retryAfter: wait });
}

app.use('/api/', rateLimit);

⚠️ An in-memory Map won't survive a cluster

This works on a single process, but if you run several instances behind a load balancer, each has its own Map and a client effectively gets N times the limit. We fix that with a shared store in the Distributed section below. In production, you'd typically reach for the express-rate-limit package with a Redis store rather than hand-rolling this.

Where & How to Enforce

Rate limiting can live at several layers, and the right choice depends on how much traffic you want to stop before it costs you resources.

flowchart LR A[Client] --> B[CDN / Edge] B --> C[API Gateway / Nginx] C --> D[Application middleware] D --> E[Business logic]
  • Edge / gateway (Nginx, Cloudflare, AWS API Gateway) β€” cheapest place to shed abusive traffic, before it hits your app at all.
  • Application (Express/Django/Laravel middleware) β€” where you have the richest context (which user, which endpoint) for precise limits.

You also choose the granularity β€” the key you count against:

KeyGood forWatch out for
IP addressAnonymous traffic; simplestMany users behind one NAT/proxy share a limit
User / accountAuthenticated, fair per-person limitsRequires login
API keyB2B APIs, tiered pricingKeys can be shared or leaked
Composite (user + endpoint)Protecting specific costly routesMost complex to reason about

A common pattern is stricter limits on sensitive routes β€” for instance, five login attempts per hour but a hundred general requests per minute:

import rateLimit from 'express-rate-limit';

const apiLimiter = rateLimit({
  windowMs: 60_000, max: 100,            // 100/min for the API
  standardHeaders: true, legacyHeaders: false,
});

const authLimiter = rateLimit({
  windowMs: 60 * 60_000, max: 5,         // 5/hour for login
  message: 'Too many login attempts, try again later',
});

app.use('/api/', apiLimiter);
app.use('/api/auth/login', authLimiter); // stacks a tighter limit

Distributed Limiting with Redis

When your API runs on multiple servers, the limit must be shared state. Redis is the standard choice: it's fast, centralized, and supports atomic operations so concurrent requests can't slip through a race condition.

The classic fixed-window counter in Redis uses INCR plus EXPIRE. To make the two operations atomic, run them as a single Lua script so no request is counted without also setting the expiry:

import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);

// Atomic: increment the window counter and set TTL on first hit
const script = `
  local current = redis.call('INCR', KEYS[1])
  if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
  end
  return current
`;

async function checkLimit(id, limit, windowSec) {
  const bucket = Math.floor(Date.now() / (windowSec * 1000));
  const key = `rl:${id}:${bucket}`;
  const count = await redis.eval(script, 1, key, windowSec);

  return {
    allowed: count <= limit,
    limit,
    remaining: Math.max(0, limit - count),
    reset: (bucket + 1) * windowSec, // unix seconds when window resets
  };
}

Using it as middleware, every server instance now shares one authoritative counter:

app.use('/api/', async (req, res, next) => {
  const id = req.user?.id ?? req.ip;
  const { allowed, limit, remaining, reset } = await checkLimit(id, 100, 60);

  res.set({
    'RateLimit-Limit': limit,
    'RateLimit-Remaining': remaining,
    'RateLimit-Reset': reset,
  });

  if (allowed) return next();
  res.set('Retry-After', reset - Math.floor(Date.now() / 1000));
  res.status(429).json({ error: 'Rate limit exceeded' });
});

βœ… Fail open, carefully

If Redis is briefly unreachable, decide deliberately whether to fail open (allow requests, prioritizing availability) or fail closed (reject, prioritizing protection). Most APIs fail open for general traffic but fail closed for auth endpoints. Whatever you choose, log it.

The 429 Response Contract

How you reject matters as much as the limit itself, because well-behaved clients rely on your response to back off. The conventions are simple and standardized.

  • Return 429 Too Many Requests β€” not 403, not 503.
  • Include a Retry-After header (seconds, or an HTTP date) telling the client when to try again.
  • Advertise the policy with RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset on every response, not just rejections.
  • Give a clear, machine-readable error body and a docs link.

A well-formed 429 response

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1618884000
Retry-After: 42

{
  "error": {
    "code": "rate_limit_exceeded",
    "message": "You have exceeded 100 requests per minute.",
    "retryAfter": 42,
    "docs": "https://api.example.com/docs/rate-limits"
  }
}

On the client side, honor Retry-After and add exponential backoff with jitter so that a fleet of clients doesn't all retry in the same instant (a "thundering herd"):

async function fetchWithRetry(url, opts = {}, attempt = 0) {
  const res = await fetch(url, opts);
  if (res.status !== 429 || attempt >= 5) return res;

  const retryAfter = Number(res.headers.get('Retry-After')) || 2 ** attempt;
  const jitter = Math.random() * 0.3 * retryAfter;  // spread out retries
  await new Promise(r => setTimeout(r, (retryAfter + jitter) * 1000));
  return fetchWithRetry(url, opts, attempt + 1);
}

Hands-on: Bucket in Action

πŸ‹οΈ Prove your limiter blocks a burst

Objective: Use the TokenBucket from Section 4 and demonstrate that it allows a burst up to capacity, then rejects, then recovers after the refill.

Instructions:

  1. Create a bucket with capacity 5 and refill rate 1 token/second.
  2. Call tryConsume() seven times in a tight loop and record which calls return true vs false.
  3. Wait two seconds, call it twice more, and record the results.
  4. Explain the pattern you see in terms of capacity and refill rate.
πŸ’‘ Hint

The bucket starts full with 5 tokens. The first five consumes succeed instantly; calls six and seven fail because the bucket is empty and almost no time has passed to refill. After waiting ~2 seconds, about 2 tokens have accrued, so the next two consumes succeed again.

βœ… Sample solution
const bucket = new TokenBucket(5, 1); // capacity 5, 1/sec

const burst = [];
for (let i = 0; i < 7; i++) burst.push(bucket.tryConsume());
console.log(burst);
// [true, true, true, true, true, false, false]
//  first 5 succeed (capacity), last 2 rejected (empty)

await new Promise(r => setTimeout(r, 2000)); // ~2 tokens refill

console.log(bucket.tryConsume(), bucket.tryConsume());
// true true  β€” the bucket recovered at 1 token/second

The capacity governs how big a burst you tolerate; the refill rate governs the sustained throughput. Tuning these two numbers is the whole art of a token-bucket limiter.

🎯 Quick Quiz

Question 1: A client sends 100 requests at 10:00:59 and 100 more at 10:01:00, exceeding a "100 per minute" limit. Which algorithm is vulnerable to this?

Question 2: Which HTTP status code and header should an API return when a client is rate limited?

Question 3: Why is an in-memory counter inadequate for rate limiting an API running on multiple servers?

Best Practices

βœ… Do

  • Use a shared store (Redis) with atomic operations for multi-instance deployments.
  • Return 429 with Retry-After and expose RateLimit-* headers on every response.
  • Apply tighter limits to sensitive routes (login, password reset, expensive queries).
  • Prefer authenticated user or API-key keys over raw IP when you can.
  • Log limit violations to spot abuse and to tune thresholds.

⚠️ Don't

  • Don't trust a spoofable X-Forwarded-For header as your only key β€” validate the proxy chain.
  • Don't rely on a per-process in-memory counter behind a load balancer.
  • Don't reject with a bare 429 and no guidance β€” clients need Retry-After.
  • Don't set limits so tight that legitimate bursts break normal usage.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Rate limiting protects availability, cost, and fairness β€” it rejects; throttling delays; quotas cap long-term volume.
  • The four algorithms are fixed window, sliding window, token bucket, leaky bucket; token bucket is the flexible default because it tolerates bursts.
  • Choose granularity deliberately: IP, user, API key, or composite.
  • For clusters, keep the counter in a shared, atomic store like Redis.
  • Reject with 429 + Retry-After and advertise the policy via RateLimit-* headers so clients can back off with jitter.

πŸ“š Further Reading

πŸš€ What's Next?

Rate limiting reduces how often you have to do work. The next lesson reduces how often you have to redo it: API response caching strategies β€” storing responses so repeated requests are answered in milliseconds.

πŸŽ‰ Traffic under control!

You can now keep any single client from overwhelming your API β€” fairly and gracefully.