Rate Limiting en APIs Node.js con Redis y Sliding Window
Poner express-rate-limit en tu API no es rate limiting. Es teatro de seguridad. Si tu backend corre en dos o más instancias detrás de un load balancer, cada réplica cuenta requests por separado y tu "límite de 100 req/min" se convierte en 200, 400 o 1,000 según cuántos pods escales. El atacante no nota la diferencia. Tu base de datos sí.
El rate limiting serio vive fuera del proceso: en un almacén compartido que todas las réplicas consultan de forma atómica. Ese almacén, en la práctica, es Redis. Y el algoritmo correcto casi nunca es el contador fijo que viene por defecto en las librerías populares: es el sliding window log, que cuesta más en memoria pero no deja ventanas de abuse entre segundos.
En este artículo aprenderás:
- Por qué
express-rate-limitcon memoria local falla en arquitecturas con más de una instancia - Los tres algoritmos clásicos (fixed window, sliding window log, token bucket) explicados con sus trade-offs reales
- Implementación paso a paso de sliding window log con
ioredisy TypeScript, con operaciones atómicas - Cómo medir si funciona usando
autocannonpara simular ráfagas y ver cuándo se rompe - Checklist de producción con los errores que hemos visto en auditorías reales a APIs Node.js
Por qué express-rate-limit en memoria local es teatro
express-rate-limit es la librería más instalada para rate limiting en Node.js: más de dos millones de descargas semanales según npm. Por defecto, guarda los contadores en la memoria del proceso. Eso funciona en un laptop de desarrollo y en una única instancia de Heroku Hobby. Se rompe en el segundo que despliegas dos réplicas.
El escenario típico es este: tienes un autoscaler de Kubernetes, un ELB de AWS o un load balancer de Render que reparte tráfico entre réplicas. Configuras un límite de 100 requests por minuto por IP. Un atacante envía 600 requests en un minuto. Como el balanceador las distribuye entre seis pods, cada uno ve 100 requests exactas y ninguno bloquea. La "protección" pasa intacta mientras tu base de datos se ahoga.
Lo mismo ocurre con serverless (Lambda, Vercel Functions, Cloudflare Workers): cada invocación arranca con memoria fresca. No hay forma de que un contador in-memory sobreviva entre requests. El único camino es mover el estado a un almacén externo que todas las réplicas puedan consultar. Redis resuelve eso con latencia sub-milisegundo y operaciones atómicas.
Antes de escribir código, vale la pena aclarar qué estás protegiendo exactamente. Rate limiting no es una sola cosa: puede ser protección contra abuse de usuarios autenticados, defensa contra brute force en el login, control de costo en endpoints caros (por ejemplo los que invocan un LLM como vimos en Cómo Integrar un LLM a tu Aplicación con Node.js), o anti-scraping en endpoints públicos. Cada caso quiere una configuración distinta.
Rate limiting rechaza el request cuando se excede el límite (HTTP 429). Throttling lo retrasa hasta que vuelva a estar dentro del límite. Para APIs públicas casi siempre quieres rate limiting: fallar rápido con 429 es más honesto que dejar colas infinitas que tiran el servicio.
Los tres algoritmos y cuándo usar cada uno
Hay tres algoritmos que cubren el 95% de los casos reales. Elegir mal no te impide dormir, pero sí deja ventanas de abuse o gasta Redis de más.
Fixed window. Divide el tiempo en ventanas fijas (por ejemplo, cada minuto de reloj). Incrementas un contador por usuario y ventana. Es barato (una sola operación INCR en Redis) pero tiene un problema conocido: en el segundo 59 de una ventana y el segundo 0 de la siguiente, un atacante puede disparar dos veces el límite. Si permites 100 req/min, puede hacer 200 en dos segundos y pasar.
Sliding window log. Guarda el timestamp de cada request en un ZSET de Redis y cuenta cuántos hay dentro de la ventana real (por ejemplo, los últimos 60 segundos desde el request actual). Es preciso, elimina el problema del fixed window y es el que recomendamos para la mayoría de APIs de PyMEs. Cuesta más memoria: O(N) por usuario donde N es el límite.
Token bucket. Modela un cubo que se llena a ritmo constante y cada request consume un token. Permite ráfagas cortas por encima del promedio mientras el bucket esté lleno. Es el que usa la API de AWS y GitHub. Más complejo de implementar bien, útil cuando quieres premiar comportamiento correcto (usuarios que no se pasan durante horas acumulan "saldo" para picos legítimos).
| Algoritmo | Memoria por usuario | Precisión | Complejidad |
|---|---|---|---|
| Fixed window | O(1) | Baja (boundary abuse) | Trivial |
| Sliding window log | O(N) | Alta | Media |
| Token bucket | O(1) | Media-alta | Alta |
Para el resto del artículo nos enfocamos en sliding window log: es el equilibrio correcto entre precisión y costo para el 90% de APIs internas o públicas de PyMEs que vemos en auditorías.
¿Tu API ya escaló a varias réplicas?
Agendá 30 minutos por Meet y revisamos en vivo los 12 puntos de rate limiting que usamos antes de aceptar un cliente de backend. Te quedás con el checklist aplicado a tu código, aunque no trabajemos juntos después.
Agendar revisión →Prerrequisitos y estructura del proyecto
Para seguir este tutorial necesitas Node.js 20+, Redis 7+ corriendo local o en tu entorno de desarrollo, y un proyecto Express con TypeScript. Si estás armando la base, la guía API REST con Node.js y TypeScript cubre la estructura completa que asumimos aquí.
Las dependencias son:
npm install express ioredis
npm install -D typescript @types/express @types/node autocannon
Usamos ioredis en lugar del cliente redis oficial porque maneja mejor la reconexión automática, soporta cluster sin configuración extra y tiene una API más ergonómica para pipelines. Es open source (licencia MIT), sin tier de pago ni dependencia de proveedor.
Levanta Redis localmente en Docker para el ejemplo:
docker run --rm -p 6379:6379 redis:7-alpine
La estructura mínima del middleware la dividimos en tres archivos: la conexión a Redis, el algoritmo sliding window y el middleware Express que los conecta.
src/
infra/
redis.ts
rate-limit/
sliding-window.ts
middleware.ts
server.ts
Implementación paso a paso del sliding window log
La idea del algoritmo es simple: cada request agrega un timestamp a un sorted set de Redis (ZSET) cuya llave es el identificador del usuario. Antes de aceptar el request, borramos los timestamps que caen fuera de la ventana y contamos cuántos quedan. Si ese conteo excede el límite, devolvemos 429.
Lo complicado no es la lógica, es garantizar que las cuatro operaciones (borrar, agregar, contar, poner TTL) se ejecuten atómicamente. Si un request corre entre el borrado y el conteo de otro, pueden colarse más requests de los permitidos. La solución es un pipeline de Redis que envía las cuatro instrucciones en un solo viaje al servidor.
Primero, la conexión:
// src/infra/redis.ts
import Redis from 'ioredis';
export const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379', {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: false,
});
redis.on('error', (err) => {
console.error('[redis] connection error', err);
});
Después, el algoritmo en sí:
// src/rate-limit/sliding-window.ts
import { redis } from '../infra/redis';
export interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number; // timestamp en ms cuando se libera un slot
}
export async function checkSlidingWindow(
key: string,
limit: number,
windowMs: number,
): Promise<RateLimitResult> {
const now = Date.now();
const windowStart = now - windowMs;
const member = `${now}-${Math.random()}`; // evita colisiones con mismo ms
const pipeline = redis.pipeline();
pipeline.zremrangebyscore(key, 0, windowStart);
pipeline.zadd(key, now, member);
pipeline.zcard(key);
pipeline.pexpire(key, windowMs);
const results = await pipeline.exec();
if (!results) {
throw new Error('redis pipeline failed');
}
const count = Number(results[2][1]);
const allowed = count <= limit;
const remaining = Math.max(0, limit - count);
const resetAt = now + windowMs;
return { allowed, remaining, resetAt };
}
Un par de decisiones técnicas merecen contexto. Usamos zremrangebyscore para limpiar timestamps viejos en la misma llamada que los agregamos, lo que mantiene el ZSET acotado al tamaño del límite aunque el usuario siga golpeando después de ser bloqueado. El pexpire final asegura que si el usuario deja de golpear, la llave desaparece sola y no acumulamos llaves muertas en Redis. Y el identificador member con Math.random() evita que dos requests en el mismo milisegundo compartan miembro y uno sobrescriba al otro (un bug sutil que solo aparece con mucha concurrencia).
Ahora el middleware Express que lo conecta:
// src/rate-limit/middleware.ts
import type { Request, Response, NextFunction } from 'express';
import { checkSlidingWindow } from './sliding-window';
interface RateLimitOptions {
limit: number;
windowMs: number;
keyGenerator?: (req: Request) => string;
}
export function rateLimit(opts: RateLimitOptions) {
const keyFor = opts.keyGenerator ?? ((req) => req.ip ?? 'unknown');
return async (req: Request, res: Response, next: NextFunction) => {
const identity = keyFor(req);
const key = `rl:${identity}:${req.route?.path ?? req.path}`;
try {
const result = await checkSlidingWindow(key, opts.limit, opts.windowMs);
res.setHeader('X-RateLimit-Limit', opts.limit);
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', Math.ceil(result.resetAt / 1000));
if (!result.allowed) {
res.setHeader('Retry-After', Math.ceil(opts.windowMs / 1000));
res.status(429).json({ error: 'rate_limit_exceeded' });
return;
}
next();
} catch (err) {
// Fail-open: si Redis no responde, no derribes toda la API
console.error('[rate-limit] redis unavailable, allowing request', err);
next();
}
};
}
La decisión de fail-open vs fail-closed cuando Redis cae es estratégica. Fail-open (dejar pasar) mantiene tu API arriba a costa de dejar temporalmente sin protección; fail-closed (bloquear todo) te protege pero te convierte en un SPOF con Redis. Para APIs comerciales típicas preferimos fail-open y alertar al equipo por Prometheus. Para endpoints críticos de pago o autenticación, fail-closed.
Uso en el servidor:
// src/server.ts
import express from 'express';
import { rateLimit } from './rate-limit/middleware';
const app = express();
app.use('/api/public', rateLimit({ limit: 60, windowMs: 60_000 }));
app.use('/api/auth/login', rateLimit({
limit: 5,
windowMs: 60_000,
keyGenerator: (req) => req.body?.email ?? req.ip ?? 'unknown',
}));
app.get('/api/public/ping', (_req, res) => res.json({ ok: true }));
app.listen(3000);
Fíjate en cómo el endpoint de login usa un keyGenerator diferente: identifica por email en lugar de IP. Eso protege contra brute force aunque el atacante rote IPs con un pool de proxies residenciales.
¿Te sirve este patrón pero no tienes tiempo de adaptarlo?
Te regresamos una revisión de 30 minutos aplicada a tu repo con comentarios concretos sobre rate limiting, auth y arquitectura. Sin pitch, dos devs revisando tu código.
Agendar revisión técnica →Medir si funciona con autocannon
Escribir el middleware es la parte fácil. Saber si realmente protege bajo carga real es la que casi nadie hace. Para eso usamos autocannon, una herramienta de benchmarking OSS (licencia MIT) que dispara miles de requests concurrentes desde la misma línea de comando.
Dispara un test que excede el límite a propósito:
npx autocannon -c 50 -d 10 -R 200 http://localhost:3000/api/public/ping
Eso es 50 conexiones concurrentes durante 10 segundos con una tasa objetivo de 200 req/s. Con límite de 60 req/min por IP, esperas ver que después del primer segundo solo pasan ~1 req/s (el goteo del sliding window) y el resto devuelven 429.
La salida te indica el porcentaje de respuestas 2xx vs non-2xx. Si ves que siguen pasando más requests de los esperados, revisa tres cosas: que Redis esté realmente compartido entre réplicas (no uno por pod), que el keyGenerator esté devolviendo el mismo valor para el mismo atacante (cuidado con IPs detrás de proxy que reportan la del proxy), y que el trust proxy de Express esté configurado correctamente.
Un error sutil que detectamos en varias auditorías: el X-Forwarded-For no se honra por defecto en Express. Sin app.set('trust proxy', true), todos los requests aparecen con la IP del load balancer y el rate limiting se convierte en global en lugar de por usuario. El atacante usa una sola IP para aniquilar a todos.
Integración con observabilidad
Los contadores de Redis son invisibles si no los exportas. Un setup mínimo sano incluye una métrica Prometheus rate_limit_hits_total{route,status} incrementada en el middleware cada vez que devuelves 429. Con eso dibujas un dashboard en Grafana que muestra abuse en tiempo real. La pieza complementaria está en el próximo post de la serie sobre observabilidad self-hosted con OpenTelemetry y Loki.
Checklist de producción
Esto es lo que revisamos antes de dar por bueno un rate limiting en una auditoría real:
- Redis compartido, no in-memory. Un solo Redis (o cluster) visible desde todas las réplicas de tu API.
- Fail-open documentado. El equipo sabe que si Redis cae, la API sigue respondiendo sin rate limiting, y hay alerta en Prometheus o el sistema equivalente.
trust proxyconfigurado. Express reconoce elX-Forwarded-Fordel load balancer para quereq.ipsea la IP real del cliente.keyGeneratorpor contexto. Login limita por email, APIs autenticadas poruserId, APIs públicas por IP.- Headers
X-RateLimit-*devueltos. Los clientes honrados (SDKs, frontends propios) pueden respetar el límite sin golpear hasta 429. - TTL de las llaves. Nunca llaves eternas en Redis. El
pexpiremantiene la base de datos acotada. - Alertas en ratio de 429. Si de pronto el 20% de los requests a
/api/publicson 429, alguien está atacando o un bug en un cliente legítimo acaba de desplegar. En cualquier caso, quieres enterarte. - Test de carga mensual.
autocannonautomatizado en CI contra staging, con umbrales claros de qué porcentaje de 429 es esperado.
El último punto es el que casi siempre falta. El rate limiting no se "implementa y olvida": cambia el tráfico, cambian los clientes, cambian los atacantes. Sin un benchmark periódico no hay manera de saber si la protección que diseñaste hace seis meses sigue vigente.
Conclusión
Rate limiting en Node.js no es instalar un paquete y seguir adelante. Es mover el estado fuera del proceso, elegir el algoritmo correcto para tu tipo de tráfico, proteger las rutas críticas con identificadores distintos de la IP y medir bajo carga real que efectivamente funciona.
Lo que cubrimos:
- ✅ Por qué
express-rate-limiten memoria se rompe con más de una réplica - ✅ Cuándo usar fixed window, sliding window log o token bucket
- ✅ Implementación atómica en Redis con
ioredisy TypeScript - ✅ Middleware Express con fail-open y headers estándar
- ✅ Benchmark real con
autocannony checklist de producción
Este post es el primero de una serie sobre Node.js production-ready. En los próximos vamos a cubrir webhooks confiables con BullMQ, observabilidad self-hosted con OpenTelemetry, y migraciones de base de datos sin downtime. Todo con herramientas open source y código probado en producción.
¿Construiste un rate limiting y no sabes si aguanta?
Corrémoslo contra tu API en 30 minutos por Meet. Sin demo de ventas, sin seguimiento invasivo. Te dejamos notas concretas de qué cambiar y qué está bien, aunque no trabajemos juntos después.
Agendar revisión técnica →