Skip to content

Rate Limiting: Token Bucket vs Sliding Window

Core Concept LearningJuly 4, 20265 min readUpdated July 17, 2026

One buggy client can retry a failing endpoint in a tight loop and starve everyone else. Rate limiting protects shared capacity and keeps abuse expensive; a single global number does not create fairness by itself.

For backend engineers protecting HTTP APIs, this article compares token bucket and sliding window, shows Redis-shaped implementations, and gives you a checkable 429 + Retry-After contract. For edge placement, see NGINX as a gateway; for auth abuse controls, see API security.

Token bucket vs sliding window rate limiting
Token bucket vs sliding window rate limiting

Skimmer: which algorithm fits?

Token bucket refills tokens at a steady rate and allows short bursts up to bucket capacity — good for normal API traffic where a dashboard may fan out a few requests at once. Sliding window counts requests in a rolling interval — stricter, fewer boundary spikes than a naive fixed window, less burst room.

Decision order: user-facing APIs that tolerate small bursts → token bucket. Login, OTP, SMS, or anti-abuse endpoints → sliding window (or a tight fixed window). Fixed window counters are simpler but can double-allow at window edges. Always return 429 with Retry-After so well-behaved clients back off.

Quick reference

  • Token bucket — burst capacity + refill rate; average rate control.
  • Sliding window — rolling count; precise; higher memory (timestamps).
  • Fixed window — easy; watch edge bursts at :00 boundaries.
  • Enforce at the edge (gateway/proxy) with shared Redis state across instances.
  • Clients must honor 429 + Retry-After to avoid retry storms.

Remember this

Login and OTP endpoints need the stricter sliding window — a bursty token bucket just gives credential stuffing more room per refill.

Token Bucket

The token bucket holds up to a maximum capacity (burst). Tokens refill continuously at a configured rate (sustained limit). Each allowed request consumes one token. If the bucket is empty, reject with 429. Example: capacity 20 and refill 100/minute allows a short burst of 20, then roughly 1.67 requests/second thereafter.

Burst-friendly behavior fits APIs where a page load may hit several endpoints at once. Keep the bucket size small enough that a burst cannot overwhelm the slowest downstream dependency. Many managed gateways and API products offer token-bucket-style quotas; always confirm the vendor's docs for your product — algorithms and defaults differ.

Token bucket: refill rate + burst capacity
Token bucket: refill rate + burst capacity

Quick reference

  • Best for: general API traffic where small bursts are expected.
  • Parameters: bucket size (max burst) + refill rate (sustained).
  • Weakness: oversized buckets still crush downstreams.
  • Use per-user and per-IP keys so one noisy neighbor does not block all.
  • Always send Retry-After on 429.
Token bucket — Redis sketch (allow path)
1// Pseudo-code: one key per user (or API key)2async function allowTokenBucket(userId: string): Promise<boolean> {3  const key = `rl:tb:${userId}`;4  const capacity = 20;5  const refillPerSec = 100 / 60; // ~1.676  const now = Date.now() / 1000;7 8  const raw = await redis.hgetall(key);9  let tokens = raw.tokens ? Number(raw.tokens) : capacity;10  let last = raw.last ? Number(raw.last) : now;11 12  tokens = Math.min(capacity, tokens + (now - last) * refillPerSec);13  if (tokens < 1) return false;14 15  tokens -= 1;16  await redis.hset(key, { tokens: String(tokens), last: String(now) });17  return true;18}
Token bucket — 429 + Retry-After
1app.use(async (req, res, next) => {2  const ok = await allowTokenBucket(req.userId);3  if (!ok) {4    const retryAfterSec = 1; // or derive from deficit / refill rate5    res.setHeader("Retry-After", String(retryAfterSec));6    res.setHeader("X-RateLimit-Limit", "100");7    res.setHeader("X-RateLimit-Remaining", "0");8    return res.status(429).json({9      error: {10        code: "rate_limited",11        message: "Too many requests — slow down and retry",12      },13    });14  }15  next();16});

Remember this

Token buckets are a solid default for APIs that need average-rate control with limited bursts.

Sliding Window

A sliding window counts requests inside a rolling duration. For 100 requests per minute, count timestamps in the last 60 seconds — not only the current clock minute. On each request, drop entries older than the window; if the count is at the limit, reject.

That avoids the classic fixed-window edge spike (100 at 00:59 and 100 at 01:00 under a "100/min" rule). Cost: memory for timestamps (Redis sorted sets with ZADD / ZREMRANGEBYSCORE / ZCARD are a common approach). Approximate sliding-window counters trade a little precision for less memory. Prefer sliding or tight windows on login and messaging endpoints where bursts are a security risk.

Sliding window: count requests in rolling interval
Sliding window: count requests in rolling interval

Quick reference

  • Best for: strict limits — login, OTP, SMS/email send, anti-abuse.
  • Strengths: precise rolling count; fewer fixed-window edge spikes.
  • Weaknesses: more memory; little intentional burst room.
  • The shown check-before-insert fixes quota consumption on reject but has a race; make the sequence atomic with Redis Lua.
  • Clients should use exponential backoff with jitter after 429.
  • Combine with per-account locks after repeated auth failures.
Check before insert — illustrative, non-atomic
1// Rejected requests are not inserted. This sketch is still non-atomic:2// concurrent callers can both observe count < limit. Use one Redis Lua3// script (remove + count + conditional add + expiry) in production.4async function allowSlidingWindow(userId: string): Promise<boolean> {5  const key = `rl:sw:${userId}`;6  const limit = 100;7  const windowMs = 60_000;8  const now = Date.now();9  const member = `${now}:${Math.random()}`;10 11  await redis.zremrangebyscore(key, 0, now - windowMs);12  const count = await redis.zcard(key);13  if (count >= limit) return false; // reject without consuming quota14 15  await redis.zadd(key, now, member);16  await redis.pexpire(key, windowMs);17  return true;18}
Sliding window — 429 response shape
1if (!(await allowSlidingWindow(req.ip))) {2  res.setHeader("Retry-After", "45");3  res.setHeader("X-RateLimit-Limit", "100");4  res.setHeader("X-RateLimit-Remaining", "0");5  res.setHeader("X-RateLimit-Reset", String(Math.ceil(Date.now() / 1000) + 45));6  return res.status(429).json({7    error: {8      code: "rate_limited",9      message: "Login rate limit exceeded — try again shortly",10    },11  });12}

Remember this

Sliding windows enforce precise limits where burst tolerance is a risk, not a feature.

Implementing rate limits in production

Put rate limiting at the edge — API gateway, NGINX, or early middleware — before business logic spends real work. Share counters in Redis (or the gateway's store) so every app instance sees the same budget. Return 429 with Retry-After and, when useful, X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset.

Tier by plan and by route: generous reads, strict /login. Monitor 429 rates — spikes can mean abuse, a client bug, or limits that are too low. For multi-region systems, decide whether limits are global or per-region; global counters add latency and consistency questions. Gateway modules (for example NGINX limit_req) implement specific algorithms — read the module docs rather than assuming "sliding window" or "token bucket" universally.

One request: empty token bucket → HTTP 429 + Retry-After
One request: empty token bucket → HTTP 429 + Retry-After

Quick reference

  • Edge first — reject before application code runs.
  • Shared Redis (or equivalent) across instances.
  • Headers: Retry-After required on 429; X-RateLimit-* optional but helpful.
  • Stricter limits on sensitive flows than on /health.
  • Log and alert on 429 spikes by route and key.

Remember this

Centralize limits at the edge with shared counters and a clear 429 contract.

Recover One Race-Condition Quota Overrun

Trigger. The non-atomic sliding-window sketch above — ZREMRANGEBYSCORE then ZCARD then a conditional ZADD, as three separate round trips — runs under a burst of 20 concurrent requests hitting the same key within milliseconds of each other. Symptom. The endpoint's configured limit is 100 requests per minute; monitoring shows 119 requests succeeded in that window. Nobody changed the limit, and no single request did anything wrong.

Root mechanism. Between the ZCARD read and the ZADD write, many of those 20 concurrent requests observe the same pre-increment count — say, 99, which is still under the limit of 100 — and all of them pass the check before any of them has actually recorded its own request. Each one independently thinks it's the request that brings the count to 100; none of them see each other's writes in time. Recovery: move the read-check-write sequence into a single Redis Lua script (EVAL), which Redis executes atomically — no other client's commands can interleave in the middle of it, closing the exact gap that let 20 requests all pass the same stale count. Verification: replay the same 20-concurrent-request burst against the Lua-script version and confirm exactly 1 (or however many the limit actually allows past the current count) succeed, with the rest receiving 429. Prevention: treat any check-then-act sequence spanning more than one round trip to shared state as a race condition by default, not an edge case — the fix is almost always collapsing it into one atomic operation, not adding a delay and hoping.

Non-atomic check-then-act: concurrent requests all read count < limit
Non-atomic check-then-act: concurrent requests all read count < limit

Quick reference

  • Trigger: a check-then-act sequence spanning multiple Redis round trips, hit by concurrent requests.
  • Symptom: the configured limit is exceeded with no single request behaving incorrectly on its own.
  • Root mechanism: many concurrent readers observe the same pre-write count before any of their writes land.
  • Recovery: a Redis Lua script executes the whole check-and-increment as one atomic unit — no interleaving possible.
  • Prevention: treat every multi-round-trip check-then-act against shared state as a race by default, not a rare edge case.
Three round trips — a real gap for concurrent requests to race through
1// Each step is a separate Redis round trip — concurrent callers2// can all execute step 2 before any of them has completed step 3.3await redis.zremrangebyscore(key, 0, now - windowMs);      // 14const count = await redis.zcard(key);                       // 2 — race window here5if (count >= limit) return false;6await redis.zadd(key, now, member);                          // 37await redis.pexpire(key, windowMs);
One atomic Lua script — no interleaving possible
1-- rate_limit.lua — runs as a single atomic unit on the Redis server2local key = KEYS[1]3local now = tonumber(ARGV[1])4local windowMs = tonumber(ARGV[2])5local limit = tonumber(ARGV[3])6local member = ARGV[4]7 8redis.call("ZREMRANGEBYSCORE", key, 0, now - windowMs)9local count = redis.call("ZCARD", key)10if count >= limit then11  return 012end13redis.call("ZADD", key, now, member)14redis.call("PEXPIRE", key, windowMs)15return 116 17-- Client: const allowed = await redis.eval(luaScript, 1, key, now, windowMs, limit, member);18-- Verify: fire 20 concurrent requests at a limit-of-100 key already at 9919-- exactly 1 succeeds, 19 receive 429, regardless of arrival timing.

Remember this

A rate limiter that checks and increments in separate round trips isn't approximately correct under concurrency — it silently overruns exactly at the arrival pattern that matters most, and the fix is one atomic Lua script, not a smaller time window.

Key takeaway

Token buckets suit general API traffic with controlled bursts. Sliding windows suit strict, abuse-sensitive endpoints. The algorithm matters less than consistent enforcement, honest headers, and clients that honor Retry-After. Prefer evidence from your traffic and vendor docs over folklore about which big product "uses" which algorithm.

Practice (20 min): Implement either algorithm behind one route. Force a 429 with curl, confirm Retry-After is present, and write a client that sleeps that many seconds before one retry — no busy-loop.

Share:

Related Articles

Rate Limiting is a critical defense mechanism for production APIs, protecting downstream microservices from traffic spik

Read

At the heart of every database system lies a Storage Engine that determines how data is written to disk, indexed, and re

Read

System design interviews evaluate a candidate's ability to architect scalable, resilient, and cost-effective distributed

Read

Keep learning

Follow a structured path or browse all courses to go deeper.