Cómo construir un rate limiter desde cero
Tutorial paso a paso de limitadores con token bucket, ventana deslizante y ventana fija: implementaciones en TypeScript, Redis y patrones de producción.

Los limitadores de velocidad protegen las API contra abusos y garantizan una distribución justa de los recursos. Ya sea que estés previniendo ataques de fuerza bruta en el inicio de sesión, limitando operaciones costosas o aplicando cuotas por plan, entender cómo funcionan los limitadores de velocidad internamente te ayuda a configurarlos y depurarlos mejor.
Este tutorial construye tres algoritmos de limitación de velocidad desde cero y luego muestra cómo desplegarlos en producción con Redis para sistemas distribuidos.
Limitador de velocidad con ventana fija
El algoritmo de ventana fija divide el tiempo en intervalos fijos y cuenta las solicitudes por intervalo. Es el enfoque más simple, pero tiene un problema de ráfagas en los límites de la ventana: un cliente puede enviar el doble del límite temporizando sus solicitudes al final de una ventana y al inicio de la siguiente.
class FixedWindowRateLimiter {
private windows: Map<string, { count: number; expiresAt: number }> =
new Map();
constructor(
private maxRequests: number,
private windowMs: number
) {}
isAllowed(key: string): boolean {
const now = Date.now();
const windowStart =
Math.floor(now / this.windowMs) * this.windowMs;
const windowKey = `${key}:${windowStart}`;
const window = this.windows.get(windowKey);
if (!window || now >= window.expiresAt) {
// New window
this.windows.set(windowKey, {
count: 1,
expiresAt: windowStart + this.windowMs,
});
return true;
}
if (window.count < this.maxRequests) {
window.count++;
return true;
}
return false;
}
}
// Usage: 100 requests per minute
const limiter = new FixedWindowRateLimiter(100, 60_000);
// Problem: At 11:00:59, send 100 requests (allowed)
// At 11:01:00, send 100 more (new window, allowed)
// Result: 200 requests in 2 seconds — double the intended rateLimitador de velocidad con registro de ventana deslizante
El registro de ventana deslizante mantiene una lista ordenada de las marcas de tiempo de las solicitudes. Cuenta las solicitudes dentro de los últimos N segundos desde el momento actual, lo que elimina el problema de ráfagas en los límites.
class SlidingWindowLogLimiter {
private logs: Map<string, number[]> = new Map();
constructor(
private maxRequests: number,
private windowMs: number
) {}
isAllowed(key: string): boolean {
const now = Date.now();
const windowStart = now - this.windowMs;
let timestamps = this.logs.get(key) ?? [];
// Remove expired entries
timestamps = timestamps.filter((t) => t > windowStart);
if (timestamps.length < this.maxRequests) {
timestamps.push(now);
this.logs.set(key, timestamps);
return true;
}
this.logs.set(key, timestamps);
return false;
}
// Returns milliseconds until next allowed request
retryAfter(key: string): number {
const now = Date.now();
const timestamps = this.logs.get(key) ?? [];
if (timestamps.length < this.maxRequests) return 0;
const oldest = timestamps[timestamps.length - this.maxRequests];
return Math.max(0, oldest + this.windowMs - now);
}
}
// ❌ Memory problem: stores every timestamp
// 10,000 users × 100 requests/min = 1M timestamps in memory
// Not practical for high-traffic APIs
// ✅ Solution: use sliding window counter (next section)Algoritmo de token bucket
El token bucket permite ráfagas mientras mantiene una velocidad promedio. Los tokens se añaden a una velocidad fija. Cada solicitud consume un token. Cuando la cubeta está vacía, las solicitudes se rechazan.
class TokenBucketLimiter {
private buckets: Map<
string,
{ tokens: number; lastRefill: number }
> = new Map();
constructor(
private capacity: number,
private refillRate: number, // tokens per second
) {}
isAllowed(key: string, tokensRequired: number = 1): boolean {
const now = Date.now();
let bucket = this.buckets.get(key);
if (!bucket) {
bucket = { tokens: this.capacity, lastRefill: now };
this.buckets.set(key, bucket);
}
// Refill tokens based on elapsed time
const elapsed = (now - bucket.lastRefill) / 1000;
bucket.tokens = Math.min(
this.capacity,
bucket.tokens + elapsed * this.refillRate
);
bucket.lastRefill = now;
if (bucket.tokens >= tokensRequired) {
bucket.tokens -= tokensRequired;
return true;
}
return false;
}
}
// 10 requests/second with burst capacity of 20
const bucket = new TokenBucketLimiter(20, 10);
// Burst: 20 requests immediately (empties bucket)
// Then: 10 requests/second sustained
// If idle for 2 seconds: bucket refills to 20Limitación de velocidad distribuida con Redis
Los limitadores de velocidad en memoria fallan cuando tienes varias instancias de servidor. Redis proporciona operaciones atómicas para la limitación de velocidad distribuida.
import Redis from "ioredis";
class RedisSlidingWindowLimiter {
constructor(
private redis: Redis,
private maxRequests: number,
private windowMs: number,
private prefix: string = "ratelimit"
) {}
async isAllowed(
key: string
): Promise<{ allowed: boolean; remaining: number; retryAfter: number }> {
const now = Date.now();
const windowStart = now - this.windowMs;
const redisKey = `${this.prefix}:${key}`;
// Use a Lua script for atomicity
const result = await this.redis.eval(
`
-- Remove expired entries
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
-- Count current entries
local count = redis.call('ZCARD', KEYS[1])
if count < tonumber(ARGV[2]) then
-- Add new entry with timestamp as score
redis.call('ZADD', KEYS[1], ARGV[3], ARGV[3] .. ':' .. math.random())
redis.call('PEXPIRE', KEYS[1], ARGV[4])
return {1, tonumber(ARGV[2]) - count - 1, 0}
else
-- Get oldest entry to calculate retry-after
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
local retryAfter = tonumber(oldest[2]) + tonumber(ARGV[4]) - tonumber(ARGV[3])
return {0, 0, retryAfter}
end
`,
1,
redisKey,
windowStart.toString(),
this.maxRequests.toString(),
now.toString(),
this.windowMs.toString()
) as [number, number, number];
return {
allowed: result[0] === 1,
remaining: result[1],
retryAfter: Math.max(0, result[2]),
};
}
}// Express middleware using the Redis rate limiter
import { Request, Response, NextFunction } from "express";
function rateLimitMiddleware(limiter: RedisSlidingWindowLimiter) {
return async (req: Request, res: Response, next: NextFunction) => {
// Rate limit by IP, or by user ID if authenticated
const key = req.user?.id ?? req.ip ?? "anonymous";
const result = await limiter.isAllowed(key);
// Always set rate limit headers
res.set("X-RateLimit-Limit", "100");
res.set("X-RateLimit-Remaining", result.remaining.toString());
if (!result.allowed) {
res.set(
"Retry-After",
Math.ceil(result.retryAfter / 1000).toString()
);
res.status(429).json({
error: "Too Many Requests",
retryAfter: Math.ceil(result.retryAfter / 1000),
});
return;
}
next();
};
}
// ❌ Same rate limit for all endpoints
// app.use(rateLimitMiddleware(globalLimiter));
// ✅ Different limits for different endpoints
// app.use("/api/auth/login", rateLimitMiddleware(strictLimiter)); // 5/min
// app.use("/api/search", rateLimitMiddleware(searchLimiter)); // 30/min
// app.use("/api", rateLimitMiddleware(defaultLimiter)); // 100/minCómo elegir el algoritmo correcto
const algorithmComparison = {
fixedWindow: {
accuracy: "Low — boundary burst problem",
memory: "O(1) per key",
complexity: "Simple",
bestFor: "Non-critical rate limiting, analytics quotas",
},
slidingWindowLog: {
accuracy: "High — no boundary issues",
memory: "O(n) per key (stores all timestamps)",
complexity: "Moderate",
bestFor: "Low-volume, high-accuracy needs",
},
slidingWindowCounter: {
accuracy: "Good — weighted average eliminates most bursts",
memory: "O(1) per key",
complexity: "Moderate",
bestFor: "General purpose API rate limiting",
},
tokenBucket: {
accuracy: "Good — controlled bursts by design",
memory: "O(1) per key",
complexity: "Moderate",
bestFor: "APIs where bursts are acceptable (CDN, uploads)",
},
};Conclusiones clave
- La ventana fija es simple pero permite ráfagas en los límites — úsala cuando una limitación aproximada sea aceptable; para una aplicación estricta, usa ventana deslizante o token bucket
- El token bucket permite ráfagas controladas — admite de forma natural patrones de tráfico irregulares mientras mantiene una velocidad promedio sostenida
- Usa Redis para la limitación de velocidad distribuida — los limitadores en memoria fallan con varias instancias de servidor; los scripts Lua en Redis proporcionan operaciones atómicas
- Devuelve siempre las cabeceras de limitación de velocidad —
X-RateLimit-Limit,X-RateLimit-RemainingyRetry-Afterayudan a los clientes a autorregularse sin alcanzar los límites - Aplica límites diferentes a cada endpoint — los endpoints de inicio de sesión necesitan límites estrictos (5/min), la búsqueda puede necesitar 30/min y los endpoints generales de la API 100/min


