Rate Limiting Inteligente com Redis e Node.js

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O Problema Real
Sua API está em produção. Um cliente mal configurado dispara 10.000 requests em 5 segundos. O banco engasga. Todos os outros clientes sofrem.
Rate limiting não é opcional. É infraestrutura básica.
Mas a maioria das implementações que vejo em produção são ingênuas. Usam fixed window, armazenam contadores em memória local e quebram com múltiplas instâncias. Vamos fazer diferente.
O Que Vamos Construir
Um sistema de rate limiting com três camadas:
- Sliding Window: precisão sem os problemas do fixed window
- Token Bucket: burst controlado para clientes legítimos
- Limites dinâmicos por tier: free, pro e enterprise com regras diferentes
Tudo com Redis como backend distribuído. Funciona com 1 ou 50 instâncias da API.
Setup Inicial
Primeiro, o projeto:
mkdir rate-limiter-api && cd rate-limiter-api
npm init -y
npm install express ioredis dotenv
npm install -D typescript @types/express @types/node tsx
npx tsc --initConfigure o tsconfig.json mínimo:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}E o .env:
REDIS_URL=redis://localhost:6379
PORT=3000Suba um Redis local se não tiver:
docker run -d --name redis-limiter -p 6379:6379 redis:7-alpineConexão com Redis
Crie o cliente Redis com reconexão automática:
// src/lib/redis.ts
import Redis from "ioredis";
import "dotenv/config";
const redis = new Redis(process.env.REDIS_URL!, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
enableReadyCheck: true,
});
redis.on("error", (err) => {
console.error("[Redis] Erro de conexão:", err.message);
});
redis.on("connect", () => {
console.log("[Redis] Conectado");
});
export default redis;O retryStrategy é importante. Em produção, o Redis pode ter blips de rede. Sem retry, sua API inteira para de responder.
Algoritmo 1: Sliding Window Log
O fixed window tem um problema clássico. Se o limite é 100 req/min, um cliente pode fazer 100 requests no segundo 59 e mais 100 no segundo 61. São 200 requests em 2 segundos.
O sliding window resolve isso.
// src/limiters/sliding-window.ts
import redis from "../lib/redis";
interface SlidingWindowResult {
allowed: boolean;
remaining: number;
retryAfter: number | null;
total: number;
}
export async function slidingWindowLimit(
key: string,
maxRequests: number,
windowMs: number
): Promise<SlidingWindowResult> {
const now = Date.now();
const windowStart = now - windowMs;
const member = `${now}:${Math.random().toString(36).slice(2, 8)}`;
// Lua script para atomicidade
const luaScript = `
local key = KEYS[1]
local window_start = tonumber(ARGV[1])
local now = tonumber(ARGV[2])
local max_requests = tonumber(ARGV[3])
local window_ms = tonumber(ARGV[4])
local member = ARGV[5]
-- Remove entradas fora da janela
redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)
-- Conta requests na janela atual
local current_count = redis.call('ZCARD', key)
if current_count < max_requests then
-- Adiciona novo request
redis.call('ZADD', key, now, member)
redis.call('PEXPIRE', key, window_ms)
return {1, max_requests - current_count - 1, 0}
else
-- Pega o request mais antigo para calcular retry
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry_after = 0
if #oldest > 0 then
retry_after = tonumber(oldest[2]) + window_ms - now
end
return {0, 0, retry_after}
end
`;
const result = (await redis.eval(
luaScript,
1,
key,
windowStart,
now,
maxRequests,
windowMs,
member
)) as number[];
return {
allowed: result[0] === 1,
remaining: result[1],
retryAfter: result[0] === 0 ? Math.ceil(result[2] / 1000) : null,
total: maxRequests,
};
}Por que Lua script? Atomicidade. Sem ele, entre o ZCARD e o ZADD, outro request poderia passar. Em alta concorrência, isso destrói a precisão do limiter.
Algoritmo 2: Token Bucket
O sliding window é preciso, mas rígido. O token bucket permite bursts controlados. Ideal para APIs onde picos curtos são aceitáveis.
A ideia é simples. Um balde tem N tokens. Cada request consome um. Tokens são repostos a uma taxa fixa.
// src/limiters/token-bucket.ts
import redis from "../lib/redis";
interface TokenBucketResult {
allowed: boolean;
remainingTokens: number;
retryAfter: number | null;
}
export async function tokenBucketLimit(
key: string,
bucketSize: number,
refillRate: number, // tokens por segundo
tokensRequired: number = 1
): Promise<TokenBucketResult> {
const luaScript = `
local key = KEYS[1]
local bucket_size = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local tokens_required = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1])
local last_refill = tonumber(bucket[2])
-- Inicializa bucket se não existe
if tokens == nil then
tokens = bucket_size
last_refill = now
end
-- Calcula tokens a adicionar desde último refill
local elapsed = (now - last_refill) / 1000
local new_tokens = elapsed * refill_rate
tokens = math.min(bucket_size, tokens + new_tokens)
local allowed = 0
local retry_after = 0
if tokens >= tokens_required then
tokens = tokens - tokens_required
allowed = 1
else
-- Tempo até ter tokens suficientes
local deficit = tokens_required - tokens
retry_after = math.ceil(deficit / refill_rate)
end
-- Salva estado
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(bucket_size / refill_rate) + 10)
return {allowed, math.floor(tokens), retry_after}
`;
const now = Date.now();
const result = (await redis.eval(
luaScript,
1,
key,
bucketSize,
refillRate,
tokensRequired,
now
)) as number[];
return {
allowed: result[0] === 1,
remainingTokens: result[1],
retryAfter: result[0] === 0 ? result[2] : null,
};
}Repare no EXPIRE do hash. Sem ele, chaves de clientes inativos ficam no Redis para sempre. Memory leak silencioso.
Sistema de Tiers Dinâmico
APIs sérias têm planos. Cada plano tem limites diferentes. Vamos modelar isso:
// src/config/tiers.ts
export interface TierConfig {
name: string;
requestsPerMinute: number;
requestsPerDay: number;
burstSize: number;
refillRate: number; // tokens/segundo
}
export const TIERS: Record<string, TierConfig> = {
free: {
name: "Free",
requestsPerMinute: 20,
requestsPerDay: 1_000,
burstSize: 10,
refillRate: 0.33, // ~20 por minuto
},
pro: {
name: "Pro",
requestsPerMinute: 200,
requestsPerDay: 50_000,
burstSize: 50,
refillRate: 3.33, // ~200 por minuto
},
enterprise: {
name: "Enterprise",
requestsPerMinute: 2_000,
requestsPerDay: 1_000_000,
burstSize: 200,
refillRate: 33.33,
},
};
export function getTier(apiKey: string): TierConfig {
// Em produção: consulta no banco/cache
if (apiKey.startsWith("ent_")) return TIERS.enterprise;
if (apiKey.startsWith("pro_")) return TIERS.pro;
return TIERS.free;
}Middleware Unificado
Agora juntamos tudo em um middleware Express que aplica ambos os algoritmos:
// src/middleware/rate-limit.ts
import { Request, Response, NextFunction } from "express";
import { slidingWindowLimit } from "../limiters/sliding-window";
import { tokenBucketLimit } from "../limiters/token-bucket";
import { getTier, TierConfig } from "../config/tiers";
function extractApiKey(req: Request): string | null {
const header = req.headers["x-api-key"];
if (typeof header === "string") return header;
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
return authHeader.slice(7);
}
return null;
}
function setRateLimitHeaders(
res: Response,
limit: number,
remaining: number,
retryAfter: number | null
) {
res.set("X-RateLimit-Limit", String(limit));
res.set("X-RateLimit-Remaining", String(Math.max(0, remaining)));
if (retryAfter) {
res.set("Retry-After", String(retryAfter));
res.set("X-RateLimit-Reset", String(
Math.ceil(Date.now() / 1000) + retryAfter
));
}
}
export function rateLimitMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const apiKey = extractApiKey(req);
if (!apiKey) {
res.status(401).json({ error: "API key obrigatória" });
return;
}
const tier = getTier(apiKey);
const identifier = `rl:${apiKey}`;
try {
// Camada 1: Token bucket para burst control
const bucketResult = await tokenBucketLimit(
`${identifier}:burst`,
tier.burstSize,
tier.refillRate
);
if (!bucketResult.allowed) {
setRateLimitHeaders(
res,
tier.burstSize,
bucketResult.remainingTokens,
bucketResult.retryAfter
);
res.status(429).json({
error: "Rate limit excedido (burst)",
tier: tier.name,
retryAfter: bucketResult.retryAfter,
});
return;
}
// Camada 2: Sliding window para limite por minuto
const windowResult = await slidingWindowLimit(
`${identifier}:min`,
tier.requestsPerMinute,
60_000
);
if (!windowResult.allowed) {
setRateLimitHeaders(
res,
tier.requestsPerMinute,
windowResult.remaining,
windowResult.retryAfter
);
res.status(429).json({
error: "Rate limit excedido (por minuto)",
tier: tier.name,
retryAfter: windowResult.retryAfter,
});
return;
}
// Camada 3: Limite diário com counter simples
const dailyKey = `${identifier}:daily:${new Date().toISOString().slice(0, 10)}`;
const dailyCount = await incrementDaily(dailyKey, tier.requestsPerDay);
if (!dailyCount.allowed) {
res.status(429).json({
error: "Limite diário excedido",
tier: tier.name,
limit: tier.requestsPerDay,
});
return;
}
// Headers de sucesso
setRateLimitHeaders(
res,
tier.requestsPerMinute,
windowResult.remaining,
null
);
res.set("X-RateLimit-Tier", tier.name);
next();
} catch (error) {
// Fail open: se Redis cair, deixa passar
console.error("[RateLimit] Erro:", error);
next();
}
};
}
async function incrementDaily(
key: string,
limit: number
): Promise<{ allowed: boolean; count: number }> {
const { default: redis } = await import("../lib/redis");
const count = await redis.incr(key);
if (count === 1) {
// Expira à meia-noite + margem
await redis.expire(key, 86400 + 3600);
}
return {
allowed: count <= limit,
count,
};
}Note o fail open no catch. Se o Redis cair, a API continua funcionando. Em produção, é melhor servir requests sem rate limiting do que derrubar tudo. Monitore esse cenário com alertas.
Servidor Express Completo
// src/server.ts
import express from "express";
import "dotenv/config";
import { rateLimitMiddleware } from "./middleware/rate-limit";
const app = express();
app.use(express.json());
// Health check sem rate limit
app.get("/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Rate limit em todas as rotas da API
app.use("/api", rateLimitMiddleware());
// Rotas de exemplo
app.get("/api/users", (_req, res) => {
res.json({
users: [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
});
});
app.post("/api/data", (req, res) => {
res.json({ received: req.body, processedAt: Date.now() });
});
// Rota para checar limites sem consumir
app.get("/api/rate-limit/status", async (req, res) => {
const apiKey = req.headers["x-api-key"] as string;
const { getTier } = await import("./config/tiers");
const tier = getTier(apiKey || "");
res.json({
tier: tier.name,
limits: {
perMinute: tier.requestsPerMinute,
perDay: tier.requestsPerDay,
burstSize: tier.burstSize,
},
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`API rodando em http://localhost:${PORT}`);
});Adicione o script de dev no package.json:
{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
}Execute:
npm run devTestando o Rate Limiter
Teste rápido com curl:
# Request normal
curl -H "x-api-key: free_test123" http://localhost:3000/api/users
# Burst de requests para testar o limite
for i in $(seq 1 25); do
echo "Request $i:"
curl -s -o /dev/null -w "HTTP %{http_code}" \
-H "x-api-key: free_test123" \
http://localhost:3000/api/users
echo ""
doneApós ~10 requests rápidos, você verá o 429 do burst. Após ~20 no minuto, o sliding window bloqueia.
Para um teste mais robusto:
// src/test/load-test.ts
async function loadTest() {
const API_KEY = "free_test123";
const URL = "http://localhost:3000/api/users";
let allowed = 0;
let blocked = 0;
const requests = Array.from({ length: 50 }, (_, i) =>
fetch(URL, { headers: { "x-api-key": API_KEY } }).then((res) => {
if (res.status === 200) allowed++;
if (res.status === 429) blocked++;
return res.status;
})
);
const results = await Promise.all(requests);
console.log(`Total: ${results.length}`);
console.log(`Permitidos: ${allowed}`);
console.log(`Bloqueados: ${blocked}`);
console.log(`Status codes:`, results);
}
loadTest();npx tsx src/test/load-test.tsMonitoramento em Produção
Rate limiting sem observabilidade é voar no escuro. Adicione métricas:
// src/middleware/rate-limit-metrics.ts
import redis from "../lib/redis";
interface RateLimitEvent {
apiKey: string;
tier: string;
blocked: boolean;
layer: "burst" | "minute" | "daily";
timestamp: number;
}
export async function trackRateLimitEvent(event: RateLimitEvent) {
const hourKey = `metrics:rl:${new Date().toISOString().slice(0, 13)}`;
const pipeline = redis.pipeline();
// Contador geral
pipeline.hincrby(hourKey, "total", 1);
pipeline.hincrby(hourKey, event.blocked ? "blocked" : "allowed", 1);
// Por tier
pipeline.hincrby(hourKey, `tier:${event.tier}:total`, 1);
// Top bloqueados (para identificar abusadores)
if (event.blocked) {
pipeline.zincrby(
`metrics:rl:blocked:${new Date().toISOString().slice(0, 10)}`,
1,
event.apiKey
);
}
// TTL de 48h para limpeza automática
pipeline.expire(hourKey, 172800);
await pipeline.exec();
}
// Consulta para dashboard
export async function getMetrics(hour: string) {
const data = await redis.hgetall(`metrics:rl:${hour}`);
return {
total: parseInt(data.total || "0"),
allowed: parseInt(data.allowed || "0"),
blocked: parseInt(data.blocked || "0"),
blockRate: data.total
? ((parseInt(data.blocked || "0") / parseInt(data.total)) * 100).toFixed(2) + "%"
: "0%",
};
}O pipeline é crucial. São 4-5 comandos Redis por request. Sem pipeline, são 4-5 round trips. Com pipeline, é 1 só.
Proteção Contra Ataques Distribuídos
Um atacante esperto usa múltiplas API keys. Adicione rate limiting por IP como camada extra:
// src/middleware/ip-limiter.ts
import { Request, Response, NextFunction } from "express";
import { slidingWindowLimit } from "../limiters/sliding-window";
export function ipRateLimit(maxPerMinute: number = 100) {
return async (req: Request, res: Response, next: NextFunction) => {
const ip =
req.headers["x-forwarded-for"]?.toString().split(",")[0].trim() ||
req.socket.remoteAddress ||
"unknown";
try {
const result = await slidingWindowLimit(
`rl:ip:${ip}`,
maxPerMinute,
60_000
);
if (!result.allowed) {
res.status(429).json({
error: "Muitos requests deste IP",
retryAfter: result.retryAfter,
});
return;
}
next();
} catch {
next(); // Fail open
}
};
}Aplique antes do rate limit por API key:
app.use("/api", ipRateLimit(100));
app.use("/api", rateLimitMiddleware());Duas camadas. IP primeiro, API key depois. Um atacante precisa furar ambas.
Minha Opinião Sincera
Vou ser direto sobre as alternativas.
express-rate-limit é o pacote mais popular. E é suficiente para projetos pequenos. Mas ele usa MemoryStore por padrão, que não funciona com múltiplas instâncias. Sim, tem o rate-limit-redis, mas a implementação é fixed window. Você herda todos os problemas que discutimos.
Upstash Ratelimit é excelente se você já usa Upstash. A SDK é limpa e o sliding window funciona bem. Mas é vendor lock-in. Quando você precisa de lógica customizada (limites por endpoint, custos diferentes por operação, rate limiting adaptativo), você bate no teto rápido.
API Gateways (Kong, AWS API Gateway) resolvem rate limiting na borda. É a abordagem certa para muitos cenários. Mas adiciona latência, custo e complexidade operacional. Para times pequenos, operar Kong em produção é mais trabalho do que implementar rate limiting no código.
A implementação que mostrei aqui tem uma vantagem que nenhuma biblioteca te dá: controle total. Você entende cada linha. Quando às 3h da manhã um cliente enterprise reclama que está sendo bloqueado indevidamente, você sabe exatamente onde olhar.
Redis como backend é a escolha certa para 95% dos casos. É rápido (sub-millisecond), distribuído por natureza e você provavelmente já tem um rodando. Os Lua scripts garantem atomicidade sem locks distribuídos.
O custo? Manutenção. Você é dono desse código. Não tem issue no GitHub para abrir. Mas para APIs de produção sérias, esse trade-off vale a pena.
Uma última coisa: sempre implemente fail open. Rate limiting é proteção, não funcionalidade core. Se o Redis cair por 30 segundos, sua API deve continuar servindo requests. Monitore, alerte, mas não derrube tudo.
Conclusão
Rate limiting inteligente tem três pilares. Algoritmo adequado ao caso de uso. Backend distribuído confiável. Observabilidade para saber o que está acontecendo.
O sliding window dá precisão. O token bucket dá flexibilidade para bursts. Tiers dinâmicos permitem monetizar sua API de forma justa. Redis garante que tudo funciona com N instâncias.
Comece com o sliding window. Adicione token bucket quando precisar de burst control. Implemente métricas desde o dia 1.
Rate limiting não é sobre bloquear usuários. É sobre proteger a experiência de todos os usuários.
## Leia tambem
- [WebSockets vs SSE](/backend/websockets-vs-server-sent-events-quando-usar-cada-um)
- [PostgreSQL com PgBouncer](/backend/escalar-postgresql-connection-pooling-pgbouncer)
- [Design Patterns TypeScript](/backend/design-patterns-typescript-dev-senior)
Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Guias de integração relacionados
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


