|$ curl https://forge-ai.dev/api/markdown?path=docs/backend/rate-limiting
$cat docs/rate-limiting.md
updated Recently·24 min read·published

Rate Limiting

BackendAPIIntermediate🎯Free Tools
Introduction

Rate limiting controls how many requests a client can make to your API in a given time window. It protects downstream services from traffic spikes, prevents abuse, ensures fair resource allocation, and gives you time to scale when load increases.

A good rate limiter is more than a counter. It chooses the right algorithm for the use case, communicates limits clearly through HTTP headers, and handles edge cases such as bursts, distributed traffic, and clock skew.

This guide covers the three most common rate limiting algorithms, the standard headers clients expect, implementation strategies, and operational considerations for production deployments.

Why Rate Limit?

Without rate limiting, a single misbehaving client can exhaust database connections, saturate CPU, or drive up infrastructure costs. Rate limiting is a defense-in-depth control that complements authentication, input validation, and caching.

ThreatHow Rate Limiting Helps
Brute force attacksCaps attempts on login and token endpoints
Resource exhaustionPrevents unbounded expensive queries
Fair useProtects free-tier users from noisy neighbors
Cost controlLimits downstream API and compute spend

info

Apply different limits to different endpoints. A search endpoint or report generator deserves a much lower limit than a metadata endpoint.
Token Bucket

The token bucket algorithm maintains a bucket with a fixed capacity. Tokens are added at a constant rate, and each request consumes one token. If the bucket is empty, the request is rejected. This allows occasional bursts up to the bucket size while enforcing an average rate over time.

Token bucket is the most popular algorithm for HTTP APIs because it is simple, memory efficient, and naturally supports bursts. A bucket with capacity ten and refill rate one per second lets a client make ten requests instantly, then one per second thereafter.

token-bucket.ts
TypeScript
1import { Redis } from "ioredis";
2
3const redis = new Redis(process.env.REDIS_URL);
4
5async function tokenBucket(
6 key: string,
7 capacity: number,
8 refillRatePerSecond: number
9): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
10 const now = Date.now();
11 const bucketKey = `ratelimit:${key}`;
12
13 const lua = `
14 local key = KEYS[1]
15 local capacity = tonumber(ARGV[1])
16 local refill = tonumber(ARGV[2])
17 local now = tonumber(ARGV[3])
18 local window = 1000 / refill
19
20 local bucket = redis.call('HMGET', key, 'tokens', 'last')
21 local tokens = tonumber(bucket[1]) or capacity
22 local last = tonumber(bucket[2]) or now
23
24 local elapsed = now - last
25 local added = (elapsed / 1000) * refill
26 tokens = math.min(capacity, tokens + added)
27
28 if tokens >= 1 then
29 tokens = tokens - 1
30 redis.call('HMSET', key, 'tokens', tokens, 'last', now)
31 redis.call('PEXPIRE', key, math.ceil(window * 2))
32 return {1, math.floor(tokens), now + math.ceil((1 - tokens) / refill * 1000)}
33 else
34 redis.call('HMSET', key, 'tokens', tokens, 'last', now)
35 return {0, math.floor(tokens), now + math.ceil((1 - tokens) / refill * 1000)}
36 end
37 `;
38
39 const result = await redis.eval(
40 lua,
41 1,
42 bucketKey,
43 capacity,
44 refillRatePerSecond,
45 now
46 ) as [number, number, number];
47
48 return {
49 allowed: result[0] === 1,
50 remaining: result[1],
51 resetAt: result[2],
52 };
53}

best practice

Implement token bucket atomically with Redis Lua scripts or Redlock to avoid race conditions in distributed deployments. A simple read-modify-write in application code will under-count under load.
Sliding Window

Sliding window counters track requests within a rolling time window rather than fixed buckets. A request is allowed if the count of requests in the previous window plus the proportion of the current window is below the limit. This avoids the burst-at-boundary problem of fixed windows.

Sliding windows are more accurate than fixed windows but require more state. The common Redis implementation stores request timestamps in a sorted set and removes entries older than the window.

sliding-window.ts
TypeScript
1async function slidingWindow(
2 key: string,
3 limit: number,
4 windowMs: number
5): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
6 const now = Date.now();
7 const windowStart = now - windowMs;
8 const sortedSetKey = `ratelimit:sliding:${key}`;
9
10 const lua = `
11 local key = KEYS[1]
12 local now = tonumber(ARGV[1])
13 local windowStart = tonumber(ARGV[2])
14 local limit = tonumber(ARGV[3])
15 local windowMs = tonumber(ARGV[4])
16
17 redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
18 local current = redis.call('ZCARD', key)
19
20 if current < limit then
21 redis.call('ZADD', key, now, now)
22 redis.call('PEXPIRE', key, windowMs)
23 return {1, limit - current - 1, now + windowMs}
24 else
25 local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
26 local resetAt = tonumber(oldest[2]) + windowMs
27 return {0, 0, resetAt}
28 end
29 `;
30
31 const result = await redis.eval(
32 lua,
33 1,
34 sortedSetKey,
35 now,
36 windowStart,
37 limit,
38 windowMs
39 ) as [number, number, number];
40
41 return {
42 allowed: result[0] === 1,
43 remaining: result[1],
44 resetAt: result[2],
45 };
46}
📝

note

Sliding window counters are accurate but more expensive in memory than token buckets. Use them when strict fairness matters more than raw throughput.
Leaky Bucket

The leaky bucket algorithm shapes traffic into a steady outflow rate. Imagine a bucket with a hole in the bottom. Requests pour in, and they leak out at a fixed rate. If the bucket overflows, requests are dropped. This enforces a smooth output rate and is useful when downstream services cannot tolerate bursts.

Leaky bucket is conceptually similar to token bucket but emphasizes rate smoothing over burst tolerance. It is commonly implemented in network traffic shaping and can be emulated with queues or token bucket configurations that do not allow large bursts.

AlgorithmBurstsFairnessMemoryUse Case
Token bucketAllowedGoodLowGeneral API limits
Sliding windowSmoothBestMediumStrict per-user limits
Leaky bucketSmoothedGoodLowTraffic shaping, queues

info

For most HTTP APIs, start with token bucket. It gives users predictable burst capacity and is easy to explain in documentation.
Standard Headers

Communicating rate limits through headers lets clients adapt before they are blocked. The IETF draft for RateLimit headers defines RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset. Many APIs also use the older X-RateLimit-* convention.

rate-limit-headers.http
HTTP
1HTTP/1.1 200 OK
2Content-Type: application/json
3RateLimit-Limit: 100
4RateLimit-Remaining: 87
5RateLimit-Reset: 1690123456
6X-RateLimit-Policy: 100;w=60
7
8{
9 "data": [...]
10}
11
12---
13
14HTTP/1.1 429 Too Many Requests
15Content-Type: application/json
16Retry-After: 45
17RateLimit-Limit: 100
18RateLimit-Remaining: 0
19RateLimit-Reset: 1690123501
20
21{
22 "error": "Rate limit exceeded",
23 "retryAfter": 45
24}

best practice

Always include Retry-After with 429 responses. It is the single most useful signal for clients to back off without guessing.
Implementation Patterns

Rate limiting usually sits in middleware. Identify the client by API key, authenticated user ID, or IP address. Choose the limit key carefully: IP-based limits are easy to evade and can punish shared NATs, while user-based limits require authentication.

rate-limit-middleware.ts
TypeScript
1import express from "express";
2
3async function rateLimitMiddleware(
4 req: express.Request,
5 res: express.Response,
6 next: express.NextFunction
7) {
8 const key = req.user?.id || req.ip || "anonymous";
9 const result = await tokenBucket(`api:${key}`, 100, 2); // 100 burst, 2/s refill
10
11 res.setHeader("RateLimit-Limit", "100");
12 res.setHeader("RateLimit-Remaining", String(result.remaining));
13 res.setHeader("RateLimit-Reset", String(Math.ceil(result.resetAt / 1000)));
14
15 if (!result.allowed) {
16 const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
17 res.setHeader("Retry-After", String(retryAfter));
18 return res.status(429).json({
19 error: "Rate limit exceeded",
20 retryAfter,
21 });
22 }
23
24 next();
25}

warning

Apply rate limiting before expensive authentication or database lookups when possible. This prevents attackers from abusing login or password reset endpoints. Use lightweight checks such as API key hash lookups.
$Blueprint — Engineering Documentation·Section ID: BE-06·Revision: 1.0