Limitación de tasa en APIs: algoritmos e implementación
Análisis detallado de token bucket, sliding window y leaky bucket, con implementaciones distribuidas en Redis y patrones prácticos de middleware.

Por qué la limitación de tasa no es negociable
Toda API pública necesita limitación de tasa. Sin ella, un solo cliente que se comporte mal —ya sea por malicia o por un simple error de programación— puede consumir todos los recursos disponibles del servidor, degradando la experiencia del resto de los usuarios. La limitación de tasa protege tu infraestructura, garantiza un acceso equitativo y ofrece un comportamiento predecible bajo carga.
El reto está en elegir el algoritmo adecuado para tu caso de uso. Los distintos algoritmos presentan compensaciones diferentes en cuanto al manejo de ráfagas, el uso de memoria y la equidad.
El algoritmo Token Bucket
El token bucket es el algoritmo de limitación de tasa más habitual. Los tokens se añaden al bucket a una tasa fija. Cada solicitud consume un token. Si el bucket está vacío, la solicitud se rechaza. El bucket tiene una capacidad máxima, lo que permite ráfagas controladas.
class TokenBucket {
private tokens: number;
private lastRefill: number;
constructor(
private readonly capacity: number,
private readonly refillRate: number // tokens per second
) {
this.tokens = capacity;
this.lastRefill = Date.now();
}
tryConsume(tokens: number = 1): boolean {
this.refill();
if (this.tokens >= tokens) {
this.tokens -= tokens;
return true;
}
return false;
}
private refill(): void {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
const newTokens = elapsed * this.refillRate;
this.tokens = Math.min(this.capacity, this.tokens + newTokens);
this.lastRefill = now;
}
getState(): { tokens: number; capacity: number } {
this.refill();
return {
tokens: Math.floor(this.tokens),
capacity: this.capacity,
};
}
}
// Usage
const bucket = new TokenBucket(100, 10); // 100 capacity, 10 tokens/sec
if (bucket.tryConsume()) {
// Process request
} else {
// Return 429 Too Many Requests
}El token bucket permite ráfagas de hasta la capacidad del bucket, manteniendo al mismo tiempo una tasa estable a largo plazo. Esto resulta ideal para APIs en las que los usuarios generan solicitudes de forma natural en ráfagas, como al cargar un panel que dispara diez llamadas a la API de forma simultánea.
El contador Sliding Window
El contador sliding window ofrece una limitación de tasa más uniforme, sin el margen de ráfaga propio del token bucket. Registra el número de solicitudes en ventanas de tiempo pequeñas e interpola entre ellas.
// ❌ Fixed window — allows 2x burst at window boundaries
// At 11:59:59 user sends 100 requests (resets at 12:00:00)
// At 12:00:01 user sends another 100 requests
// Result: 200 requests in 2 seconds despite 100/minute limit
// ✅ Sliding window — smooth rate limiting across boundaries
class SlidingWindowCounter {
private windows: Map<string, number> = new Map();
private readonly windowSize: number; // in milliseconds
constructor(
private readonly maxRequests: number,
private readonly windowMs: number
) {
this.windowSize = windowMs;
}
tryConsume(clientId: string): { allowed: boolean; remaining: number; resetMs: number } {
const now = Date.now();
const currentWindow = Math.floor(now / this.windowSize);
const previousWindow = currentWindow - 1;
const currentKey = `${clientId}:${currentWindow}`;
const previousKey = `${clientId}:${previousWindow}`;
const currentCount = this.windows.get(currentKey) || 0;
const previousCount = this.windows.get(previousKey) || 0;
// Weight the previous window by how much of it overlaps
const elapsedInWindow = (now % this.windowSize) / this.windowSize;
const weightedCount =
previousCount * (1 - elapsedInWindow) + currentCount;
if (weightedCount >= this.maxRequests) {
const resetMs = this.windowSize - (now % this.windowSize);
return {
allowed: false,
remaining: 0,
resetMs,
};
}
this.windows.set(currentKey, currentCount + 1);
this.cleanup(currentWindow);
return {
allowed: true,
remaining: Math.floor(this.maxRequests - weightedCount - 1),
resetMs: this.windowSize - (now % this.windowSize),
};
}
private cleanup(currentWindow: number): void {
for (const key of this.windows.keys()) {
const windowNum = parseInt(key.split(":")[1], 10);
if (windowNum < currentWindow - 1) {
this.windows.delete(key);
}
}
}
}Limitación de tasa distribuida con Redis
Los limitadores de tasa en memoria fallan en sistemas distribuidos porque cada servidor mantiene su propio conteo. Redis ofrece operaciones atómicas para compartir el estado entre todas las instancias.
import { Redis } from "ioredis";
class RedisRateLimiter {
constructor(private readonly redis: Redis) {}
async slidingWindowLimit(
key: string,
maxRequests: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; retryAfter: number }> {
const now = Date.now();
const windowMs = windowSeconds * 1000;
const windowStart = now - windowMs;
const pipeline = this.redis.pipeline();
// Remove expired entries
pipeline.zremrangebyscore(key, "-inf", windowStart);
// Add current request
pipeline.zadd(key, now, `${now}:${Math.random()}`);
// Count requests in window
pipeline.zcard(key);
// Set TTL to auto-cleanup
pipeline.expire(key, windowSeconds + 1);
const results = await pipeline.exec();
const requestCount = results?.[2]?.[1] as number;
if (requestCount > maxRequests) {
// Remove the request we just added
await this.redis.zremrangebyscore(key, now, now);
// Calculate retry-after from oldest request in window
const oldest = await this.redis.zrange(key, 0, 0, "WITHSCORES");
const oldestTime = oldest.length > 1 ? parseInt(oldest[1], 10) : now;
const retryAfterMs = oldestTime + windowMs - now;
return {
allowed: false,
remaining: 0,
retryAfter: Math.ceil(retryAfterMs / 1000),
};
}
return {
allowed: true,
remaining: maxRequests - requestCount,
retryAfter: 0,
};
}
}Implementación de middleware en Express
Envuelve el limitador de tasa en un middleware que se encargue de las cabeceras de respuesta, las respuestas de error y la identificación del cliente.
import { Request, Response, NextFunction } from "express";
interface RateLimitConfig {
maxRequests: number;
windowSeconds: number;
keyGenerator: (req: Request) => string;
skip?: (req: Request) => boolean;
onLimitReached?: (req: Request) => void;
}
function createRateLimitMiddleware(
limiter: RedisRateLimiter,
config: RateLimitConfig
) {
return async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
if (config.skip?.(req)) {
next();
return;
}
const key = `ratelimit:${config.keyGenerator(req)}`;
const result = await limiter.slidingWindowLimit(
key,
config.maxRequests,
config.windowSeconds
);
// Always set rate limit headers
res.setHeader("X-RateLimit-Limit", config.maxRequests);
res.setHeader("X-RateLimit-Remaining", result.remaining);
res.setHeader(
"X-RateLimit-Reset",
Math.ceil(Date.now() / 1000) + config.windowSeconds
);
if (!result.allowed) {
res.setHeader("Retry-After", result.retryAfter);
config.onLimitReached?.(req);
res.status(429).json({
error: "Too Many Requests",
message: `Rate limit exceeded. Try again in ${result.retryAfter} seconds.`,
retryAfter: result.retryAfter,
});
return;
}
next();
};
}
// Usage with different tiers
const apiLimiter = createRateLimitMiddleware(limiter, {
maxRequests: 100,
windowSeconds: 60,
keyGenerator: (req) => req.headers["x-api-key"] as string || req.ip || "unknown",
skip: (req) => req.path === "/health",
onLimitReached: (req) => {
console.warn(`Rate limit hit: ${req.ip} on ${req.path}`);
},
});
const authLimiter = createRateLimitMiddleware(limiter, {
maxRequests: 5,
windowSeconds: 300,
keyGenerator: (req) => `auth:${req.ip}`,
});Limitación de tasa por niveles
Cada tipo de consumidor de la API merece límites distintos. Los usuarios del nivel gratuito reciben límites más bajos; los usuarios premium, límites más altos; y los servicios internos necesitan límites aún mayores o incluso acceso ilimitado.
interface RateLimitTier {
name: string;
requestsPerMinute: number;
requestsPerDay: number;
burstCapacity: number;
}
const tiers: Record<string, RateLimitTier> = {
free: {
name: "Free",
requestsPerMinute: 30,
requestsPerDay: 1000,
burstCapacity: 10,
},
pro: {
name: "Professional",
requestsPerMinute: 300,
requestsPerDay: 50000,
burstCapacity: 50,
},
enterprise: {
name: "Enterprise",
requestsPerMinute: 3000,
requestsPerDay: 500000,
burstCapacity: 200,
},
};
async function getTierForApiKey(apiKey: string): Promise<RateLimitTier> {
const cached = await redis.get(`tier:${apiKey}`);
if (cached) return JSON.parse(cached);
const tier = await db.query(
"SELECT tier FROM api_keys WHERE key_hash = $1",
[hashApiKey(apiKey)]
);
const result = tiers[tier?.tier || "free"];
await redis.set(`tier:${apiKey}`, JSON.stringify(result), "EX", 300);
return result;
}Puntos clave
La limitación de tasa protege tu API frente al abuso y garantiza un acceso equitativo entre todos los consumidores. Elige el token bucket para las APIs que se benefician de un margen de ráfaga, y el contador sliding window para una limitación más suave y predecible. Usa Redis para la limitación de tasa distribuida, de modo que todas las instancias de la aplicación compartan los mismos contadores.
Devuelve siempre las cabeceras HTTP adecuadas (X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After) para que los clientes puedan autorregularse. Implementa límites de tasa por niveles que se ajusten a tu modelo de precios: los usuarios gratuitos reciben límites más bajos y los clientes de pago, límites más altos. Separa los límites de tasa para los endpoints sensibles, como la autenticación, que necesitan controles mucho más estrictos que los endpoints generales de la API.
El mejor limitador de tasa es aquel que tus consumidores de la API nunca notan, porque los límites son lo bastante generosos para el uso normal y, a la vez, lo bastante estrictos para protegerse contra el abuso.


