#DevOps

API Rate Limiting Strategies In Depth: Algorithms and Implementation

Rate limiting is more than 'N requests per minute' — which algorithm (fixed window/sliding window/token bucket/leaky bucket), which dimension (IP/user/API key), where to store (memory/Redis), and what to return when exceeded (429/Retry-After) all matter.

Key Points

  • Topic Focus: DevOps
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Many teams' understanding of rate limiting stops at "add a middleware, 100 times per minute." Only after an incident do they discover: boundary bursts blew up the database, an entire floor of users behind NAT was wrongly killed, the count was completely inaccurate under concurrency, and the client kept hitting the wall without knowing when to retry.

Rate limiting isn't as simple as "add an N." It must answer four questions: which algorithm, which dimension, where to store, and what to return when exceeded. Each question has its pitfalls. This article explains the four questions thoroughly and ends with a production-grade solution you can copy directly.

1. The Purpose of Rate Limiting: Not Just Anti-DDoS

Rate limiting is often equated with "anti-hacker." Wrong. Its core goals are four, in no particular order of importance:

  1. Protect services — downstream database connection pools and third-party API quotas all have upper limits. When burst traffic hits, cascading failures, avalanches, and site-wide 503s all come from this.
  2. Fairness — a single user/tenant can't monopolize resources, or other users starve. For multi-tenant SaaS, this is the baseline.
  3. Anti-abuse — crawlers, deal-hunting, and credential-stuffing brute force all must be blocked by rate limiting.
  4. Billing — for SaaS that bills by API call count, rate limiting is a prerequisite for billing: you can only count, and only charge, what you can limit.

So rate limiting is the API's "immune system" — it should be there by default, not patched in after an incident.

2. Four Algorithms: Each with Its Own Temperament

1. Fixed Window

Slice time into fixed windows (e.g., one per minute), count within the window, reject when the threshold is exceeded, and reset to zero at the end of the window.

text
00:00–00:59  Reject once count reaches N
01:00        Reset to zero, count again

Fatal flaw: boundary bursts. The user sends 100 requests at 00:59 (full quota) and another 100 at 01:01 — within 2 seconds, 200 actually pass through, and the window boundary becomes a traffic spike. If the downstream can't withstand this 2-second 2x peak, the fixed window won't save you.

The Redis implementation is ridiculously simple:

redis
INCR    ratelimit:user:123:202607171530
EXPIRE  ratelimit:user:123:202607171530 60

Suitable for background tasks and simple counting where precision isn't critical. Don't use only this for production APIs.

2. Sliding Window

Instead of "slicing by natural minutes," it "looks back 60 seconds from now." Use a Redis sorted set to store the timestamp of each request; on each arrival, first clean up expired members, then count the number within the current window.

lua
-- KEYS[1] rate limit key
-- ARGV[1] current timestamp (ms)  ARGV[2] window size (ms)  ARGV[3] max request count
local now, window, limit = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)  -- Clean outside the window
local count = redis.call('ZCARD', KEYS[1])
if count >= tonumber(ARGV[3]) then return 0 end
redis.call('ZADD', KEYS[1], now, now)
redis.call('PEXPIRE', KEYS[1], window)
return 1

Smooth, no bursts, high precision. The cost is memory: each request occupies one zset member. Suitable for critical APIs and billing scenarios.

3. Token Bucket

Tokens are generated in the bucket at a constant rate; requests consume tokens, and when the bucket is empty, they're rejected. Key feature: allows short bursts. After being idle for a while, the bucket fills up, and the next call can consume multiple tokens at once; after that, it returns to a steady rate.

The token bucket is the standard for API ingress rate limiting. Reason: API calls are naturally bursty (the front end fires a dozen concurrent requests during page load), and the token bucket can accommodate such normal bursts, whereas fixed/sliding windows would wrongly kill them.

lua
-- KEYS[1] bucket key
-- ARGV: [capacity, generation rate (tokens/sec), current time (sec), cost this time]
local capacity, rate, now, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now

tokens = math.min(capacity, tokens + math.max(0, now - ts) * rate)  -- Refill tokens
local allowed = 0
if tokens >= cost then tokens = tokens - cost; allowed = 1 end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) * 2)
return allowed

4. Leaky Bucket

Requests are like water poured into a bucket, leaking out the bottom at a constant rate. When the bucket is full, they're rejected. The output rate is strictly constant and doesn't accept bursts.

Leaky bucket vs token bucket: the leaky bucket enforces constant-rate output, while the token bucket allows bursts. So they serve different purposes:

  • The leaky bucket suits downstream protection — e.g., calling a third-party payment API, forcing smoothing to avoid being rate-limited or treated as an attack by the other side.
  • The token bucket suits ingress rate limiting — allows normal bursts and only blocks abnormal traffic.

Algorithm Comparison Table

AlgorithmImplementation ComplexityMemoryBurstsPrecisionTypical Scenario
Fixed WindowVery lowO(1)Boundary burstsPoorBackground tasks, coarse-grained counting
Sliding WindowMediumO(N)SmoothHighCritical APIs, billing
Token BucketMediumO(1)SupportedHighAPI ingress (mainstream)
Leaky BucketMediumO(1)Not supportedHighDownstream protection, queue peak-shaving

3. Which Dimension to Limit By: IP, User, Key, or Global

Choosing a dimension answers the question "who is the traffic subject":

  • IP — the only identifiable dimension for anonymous users. The pitfall is NAT/company egress sharing: one office building, one IP; if the threshold is too low, the whole company gets wrongly killed. IP rate limiting should be relaxed accordingly.
  • User ID — most accurate for logged-in users. But authentication is required first to determine it, and the same user across multiple devices must be merged into the same counter.
  • API Key — most accurate for third-party integrations; the key is directly bound to the customer and billing account.
  • Global — regardless of source, protects the service as a whole. The last line of defense.

In practice, it's a multi-dimensional combination, not an either-or. The next section explains why.

4. Where to Store: Is Memory Enough, How to Use Redis

  • Single-machine memory — sufficient for a single instance, with the lowest latency. Not shared across instances, lost on restart; fine for development or small scale.
  • Redis — shared across instances, the mainstream solution. One Redis round trip per request, with latency in the millisecond range.
  • Redis + Lua — encapsulates "read count + decide + write back" into an atomic operation. Production grade must go through Lua, otherwise the count is completely inaccurate under concurrency.

Why Lua is mandatory? Consider the three steps INCR → decide → EXPIRE: two concurrent requests both read count=99 (limit 100), both decide to pass, and 101 actually get through. Redis executes Lua scripts single-threaded, making the whole segment atomic and eliminating this race. The three Lua snippets above were written for exactly this.

5. Over-Limit Response: Don't Silently Drop, Don't Return 500 Either

When the limit is exceeded, give the client a clear signal, rather than silently dropping requests or returning 500 (that's the server's fault). The standard practice:

HTTP 429 Too Many Requests + Retry-After header:

text
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json

{"error": "rate_limit_exceeded", "retry_after": 30}

X-RateLimit- headers* (popularized early by Twitter, a de facto standard):

text
X-RateLimit-Limit:     100        Window upper limit
X-RateLimit-Remaining: 73         Remaining
X-RateLimit-Reset:     1698902400 Reset time (Unix seconds)

Giving clients this information lets them adaptively back off, show "how many are left" in the UI, and avoid repeatedly hitting the wall. This is API etiquette and a cheap way to reduce customer support tickets.

6. Multi-Layer Rate Limiting: One Line of Defense Is Not Enough

Single-layer rate limiting can't cover everything. Production architectures are usually onion-style, with multiple layers:

text
Request → [Global 100k/s] → [IP 100/s] → [User 1000/h] → [API Key 100/min] → Service
  • Global limit: prevents overall blowup, protects the database and downstream.
  • User limit: ensures fairness, prevents a single tenant from monopolizing.
  • IP limit: blocks anonymous abusers and crawlers.

Each layer can use a different algorithm: global uses a leaky bucket (smooths input to downstream), user uses a token bucket (allows normal bursts), IP uses a sliding window (precisely blocks abuse). A trigger at any layer results in rejection; each has its own responsibility.

7. Implementation: Build Your Own Lua or Use a Library

Two paths:

Build your own Redis + Lua — fully controllable, suitable for special needs (complex layering, custom response headers, integration with billing systems). The cost is handling script distribution, Redis failure degradation, and monitoring/alerting yourself. The Lua scripts above can serve as a starter template.

Use an existing library —

  • Node.js: express-rate-limit + rate-limit-redis (simple scenarios), @upstash/ratelimit (Serverless-friendly, with multiple algorithms built in)
  • Go: golang.org/x/time/rate (single-machine token bucket), github.com/go-redis/redis_rate (distributed)
  • Java: Bucket4j (token bucket, supports Redis), Resilience4j RateLimiter

My advice: start with a library, then sink to your own Lua once things run smoothly. Many teams do the opposite, reinventing the wheel from the start, hitting pitfall one of inaccurate concurrent counting, pitfall two of site-wide rejection when Redis hiccups, and finally silently switching back to a library. First get rate limiting running with a library; only sink lower when the business is complex enough that the library can't hold up (e.g., needing atomic integration with the billing system).

Summary

  1. The purpose of rate limiting goes beyond anti-DDoS — protecting services, fairness, anti-abuse, and billing; all four goals must be covered.
  2. Algorithm selection depends on the scenario — API ingress uses a token bucket (allows bursts), downstream protection uses a leaky bucket (forces smoothing), critical billing uses a sliding window (precise); don't let a fixed window's boundary burst wake you up in the middle of the night.
  3. Production grade requires Redis + Lua — atomic operations are the baseline; otherwise the count drifts absurdly under concurrency.
  4. Responses must be standardized — 429 + Retry-After + X-RateLimit-* so clients can adapt rather than blindly crash.
  5. Multi-layer rate limiting — global/user/IP each mind their own patch, algorithms can differ, and a single layer is never enough.

Key insight: Rate limiting isn't about denying service; it's a contract — telling the client clearly "how much you can use and when you can come again." When both sides act according to the contract, the system stays stable.

Related

Related Content