Rate limiting é uma daquelas defesas que só ganham atenção quando faltam. Sem elas, um único cliente abusivo derruba o seu banco, estoura a conta de e-mail transacional ou faz brute force no login enquanto você dorme. Neste estudo de caso prático eu implemento a mesma proteção, limite geral por IP mais limite estrito no login, com Redis para funcionar em múltiplas instâncias, em quatro pilhas do ecossistema Node. O foco é a verbosidade real, o esforço de setup e, principalmente, o nível de segurança que cada abordagem entrega na prática.
Comecei a comparação por Next e Nuxt de propósito. Na prática, é raro ver APIs construídas sobre o back-end desses frameworks full-stack, já que a comunidade costuma preferir um back-end separado e dedicado, como Express ou NestJS. Mas o back-end deles existe, é robusto e protege endpoints muito bem, então vale mostrar como se faz ali antes de ir para as opções mais comuns.
Antes do código: os algoritmos que importam
Rate limiting não é um conceito único. A diferença entre as estratégias muda o comportamento sob rajada e a precisão da contagem, então vale entender as quatro principais antes de comparar implementações.
Fixed window. Conta requisições em janelas fixas de tempo, por exemplo cem por minuto reiniciando a cada minuto cheio. É simples e barato, mas sofre do problema da borda: um cliente pode mandar cem requisições no fim de uma janela e mais cem no começo da seguinte, dobrando o limite efetivo num intervalo curto.
Sliding window. Desliza a janela de forma contínua, ponderando a janela anterior. Resolve o problema da borda e entrega uma contagem muito mais justa, ao custo de um pouco mais de processamento. É o padrão recomendado para APIs públicas.
Token bucket. Mantém um balde de fichas que reabastece a uma taxa fixa. Cada requisição gasta uma ficha. Permite rajadas curtas dentro do saldo acumulado, sendo ótimo para cargas irregulares mas legítimas.
Leaky bucket. Processa requisições a uma vazão constante, enfileirando o excesso. Suaviza picos, mas adiciona latência ao segurar requisições na fila.
Outro ponto decisivo é onde o contador vive. Um limitador em memória funciona numa única instância, mas em produção com várias réplicas atrás de um load balancer cada instância passa a ter o seu próprio contador, e o limite real vira o seu número multiplicado pela quantidade de réplicas. É por isso que todos os exemplos abaixo usam Redis: um contador central compartilhado com operações atômicas de incremento e expiração. Se você já leu o meu artigo sobre filas com BullMQ e Redis no NestJS, vai reconhecer o mesmo Redis aqui cumprindo outro papel de coordenação entre processos.
O contrato comum
Para comparar de forma justa, toda implementação precisa cobrir os mesmos quatro pontos. São eles que vamos avaliar em cada framework.
Limite global. Um teto de requisições por cliente em toda a API, por exemplo cem por minuto.
Limite estrito por rota. Uma regra muito mais apertada em endpoints sensíveis como o login, por exemplo cinco tentativas por minuto.
Contador compartilhado. Estado em Redis para que o limite valha no conjunto, não por réplica.
Headers e resposta 429. Devolver o status correto e os headers padronizados de rate limit para o cliente se ajustar.
Next: o desafio do serverless e do edge
Começo pelo Next porque ele é o que mais exige entendimento do ambiente. O Next não tem rate limiting embutido, e a forma de implementar depende diretamente de onde a sua API roda. É aí que mora a diferença entre uma proteção real e uma falsa sensação de segurança.
Por que contador em memória não funciona aqui
Em deploy serverless, o modelo padrão da Vercel e de plataformas similares, cada invocação de uma função pode subir uma instância nova e efêmera, que morre logo depois. Um contador guardado numa variável de memória não sobrevive entre requisições, então o limite simplesmente não existe na prática. Pior: em desenvolvimento local ele parece funcionar, o que gera uma armadilha clássica de iniciante, o código passa nos testes e falha silenciosamente em produção. A conclusão é direta: no Next, estado externo não é luxo, é requisito. Redis serverless resolve isso, e a biblioteca de referência é a @upstash/ratelimit, que fala com um Redis acessível por HTTP, compatível com o ambiente edge.
Onde aplicar: edge middleware versus route handler
O Next oferece dois pontos para interceptar. O middleware.ts roda no edge, antes da requisição chegar à rota, o que é ideal para o limite global porque bloqueia abuso o mais cedo possível, perto do usuário e longe da sua origem. Já o limite estrito de uma rota específica fica melhor dentro da própria route handler, mantendo a regra de exceção colada ao endpoint que ela protege. Vamos aos dois.
// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
// Limite global: sliding window de 100 req por minuto
export const apiRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, "60 s"),
prefix: "rl:api",
analytics: true,
});
// Limite estrito de login: 5 tentativas por minuto
export const authRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, "60 s"),
prefix: "rl:auth",
analytics: true,
});O middleware global, no edge, intercepta toda a área de API e aplica o limite por IP. Note que a leitura do IP é manual, a partir dos headers de proxy, e isso tem implicação de segurança que discuto logo abaixo.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { apiRatelimit } from "@/lib/rate-limit";
export async function middleware(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith("/api/")) {
return NextResponse.next();
}
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ??
request.headers.get("x-real-ip") ??
"127.0.0.1";
const { success, limit, remaining, reset } = await apiRatelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: "Muitas requisicoes" },
{
status: 429,
headers: {
"RateLimit-Limit": String(limit),
"RateLimit-Remaining": String(remaining),
"RateLimit-Reset": String(reset),
},
}
);
}
return NextResponse.next();
}
export const config = { matcher: "/api/:path*" };// app/api/login/route.ts
import { NextRequest, NextResponse } from "next/server";
import { authRatelimit } from "@/lib/rate-limit";
export async function POST(req: NextRequest) {
const ip =
req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "127.0.0.1";
const { success } = await authRatelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: "Muitas tentativas de login" },
{ status: 429 }
);
}
return NextResponse.json({ ok: true });
}O cuidado com o IP forjado
Extrair o IP lendo x-forwarded-for na mão é o ponto sensível. Esses headers podem ser falsificados pelo cliente se a aplicação não estiver atrás de um proxy confiável que os sobrescreva. Em plataformas como a Vercel, a borda já normaliza esse header, então confiar nele é seguro. Fora desse cenário, valide a origem ou use o identificador que a sua plataforma expõe. Esse princípio de só confiar no que se pode verificar é o mesmo que discuti no artigo sobre autenticação stateless e stateful: identidade não confirmada não é identidade.
O ponto central do Next é a aplicação no edge. O limite roda perto do usuário, antes de tocar a sua origem, com um Redis serverless que escala junto. Em troca de algumas linhas a mais, você tem rate limiting de produção com sliding window, o algoritmo mais justo para API pública.
Nuxt: configuração enxuta, com atenção ao storage
O Nuxt, sobre o motor Nitro, surpreende pela facilidade quando você aceita um módulo. Mas antes de tudo, uma correção de precisão que muita gente erra: o Nuxt e o Nitro não têm rate limiter nativo embutido. O routeRules, frequentemente associado a isso, é um recurso nativo do Nitro voltado a cache, redirecionamento, prerender e cabeçalhos, não a rate limiting. Quem entrega a proteção é um módulo da comunidade, e o mais usado é o nuxt-security.
O caminho fácil: nuxt-security via configuração
Instalado o módulo, o rate limiter liga por configuração, global e por rota, sem você escrever uma linha de middleware. É o caminho mais curto entre as quatro pilhas para ter uma proteção em pé.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["nuxt-security"],
security: {
rateLimiter: {
tokensPerInterval: 100,
interval: 60_000,
headers: true,
},
},
routeRules: {
"/api/login": {
security: {
rateLimiter: {
tokensPerInterval: 5,
interval: 60_000,
headers: true,
},
},
},
},
});A pegadinha do armazenamento
Aqui está o detalhe que separa um tutorial de um conselho de produção. Por padrão, o rate limiter do módulo usa o armazenamento local do Nitro, que recai no mesmo problema das múltiplas instâncias: cada réplica conta sozinha. A conveniência da configuração esconde essa armadilha, e ignorá-la dá uma falsa sensação de proteção. Para produção distribuída, você aponta o driver de storage do Nitro para Redis. O Nitro abstrai isso por um sistema de drivers de armazenamento, o que mantém o seu código de aplicação intacto enquanto troca o backend de estado.
Quando você quer controle: middleware Nitro próprio
Se precisar de controle fino sobre a chave ou o algoritmo, escreve um middleware de servidor direto sobre o Nitro, usando o Redis na mão. O exemplo abaixo usa fixed window com incremento atômico e expiração, deliberadamente cru para deixar o mecanismo visível.
// server/middleware/rate-limit.ts
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
export default defineEventHandler(async (event) => {
if (!event.path.startsWith("/api/")) return;
const ip =
getRequestHeader(event, "x-forwarded-for")?.split(",")[0].trim() ??
"127.0.0.1";
const key = `rl:api:${ip}`;
const limit = 100;
const windowSec = 60;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, windowSec);
}
setResponseHeader(event, "RateLimit-Limit", String(limit));
setResponseHeader(event, "RateLimit-Remaining", String(Math.max(0, limit - count)));
if (count > limit) {
throw createError({ statusCode: 429, statusMessage: "Muitas requisicoes" });
}
});O Nuxt, portanto, oferece os dois extremos: o caminho de configuração para quem quer velocidade, e o controle total do middleware Nitro para quem precisa. A única regra inegociável é não esquecer o storage compartilhado. Resolvido isso, o Nuxt faz rate limiting de produção com muito pouco atrito.
Express: o middleware maduro
Saindo dos full-stack, chegamos ao território onde rate limiting é tema resolvido há anos. No Express a solução de referência é a express-rate-limit, hoje na versão 8. Ela é um middleware clássico: você cria limitadores e os aplica nas rotas. O store de Redis vem da rate-limit-redis, e os headers seguem o rascunho padronizado draft-8 da IETF, em vez dos antigos headers proprietários.
import express from "express";
import { rateLimit } from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const sendCommand = (...args: string[]) => redis.sendCommand(args);
// Limite geral: 100 requisicoes por IP a cada 15 minutos
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: "draft-8",
legacyHeaders: false,
store: new RedisStore({ sendCommand }),
keyGenerator: (req) => req.user?.id ?? req.ip,
});
// Limite estrito para login: 5 tentativas por 15 minutos
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
standardHeaders: "draft-8",
legacyHeaders: false,
store: new RedisStore({ sendCommand, prefix: "rl:auth:" }),
keyGenerator: (req) => req.ip,
handler: (req, res) => {
res.status(429).json({ error: "Muitas tentativas. Tente mais tarde." });
},
});
const app = express();
app.set("trust proxy", 1);
app.use("/api", apiLimiter);
app.post("/api/login", authLimiter, (req, res) => {
res.json({ ok: true });
});
app.listen(3000);O trust proxy é obrigatório quando a aplicação roda atrás de um proxy reverso, senão o req.ip aponta para o proxy e todos os clientes compartilham o mesmo contador. E o keyGenerator por usuário autenticado, com fallback para IP, evita punir vários usuários legítimos que compartilham um mesmo IP de saída, problema clássico em redes corporativas e operadoras móveis.
NestJS: rate limiting declarativo com guard
O Nest tem solução oficial, o @nestjs/throttler. A filosofia é declarativa: você configura limitadores nomeados no módulo, registra um guard global e ajusta exceções por rota com decorators. O storage de Redis vem de um adapter dedicado, mantendo o contador compartilhado entre instâncias.
// app.module.ts
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { ThrottlerModule, ThrottlerGuard, seconds } from "@nestjs/throttler";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import Redis from "ioredis";
@Module({
imports: [
ThrottlerModule.forRoot({
throttlers: [
{ name: "short", ttl: seconds(10), limit: 30 },
{ name: "long", ttl: seconds(60), limit: 120 },
],
storage: new ThrottlerStorageRedisService(new Redis(process.env.REDIS_URL)),
}),
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}O destaque é o suporte nativo a múltiplos throttlers simultâneos. O exemplo combina um limite de rajada, trinta requisições em dez segundos, com um limite sustentado, cento e vinte por minuto. Os dois valem ao mesmo tempo, cobrindo tanto o pico abusivo quanto o consumo prolongado. No controller, você sobrescreve a regra apenas onde precisa.
// auth.controller.ts
import { Controller, Post } from "@nestjs/common";
import { Throttle, SkipThrottle } from "@nestjs/throttler";
@Controller("auth")
export class AuthController {
// Sobrescreve o limite global: 5 tentativas por 60s nesta rota
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@Post("login")
login() {
return { ok: true };
}
// Healthcheck fica fora do rate limiting
@SkipThrottle()
@Post("health")
health() {
return { status: "up" };
}
}A abordagem do Nest é a mais limpa para projetos grandes: o rate limiting vira uma preocupação transversal resolvida por guard, e cada rota declara só o que foge do padrão. Contadores e bloqueios também são métricas valiosas para detectar ataques em andamento, tema que cruza com o meu artigo sobre observabilidade em Node e NestJS.
O comparativo técnico
Montei a mesma proteção completa nas quatro pilhas, com limite global, limite estrito de login e Redis compartilhado, e contei apenas as linhas que o desenvolvedor escreve, ignorando linhas em branco. Para o Nuxt usei a abordagem idiomática via módulo. O primeiro gráfico mostra a verbosidade de cada uma.
Mas verbosidade sozinha engana. Um código curto pode esconder uma pegadinha de produção, e um código longo pode entregar mais segurança. Por isso o segundo gráfico cruza dois eixos numa pontuação de zero a dez: o esforço de setup, onde mais é pior, e a cobertura de segurança pronta para uso, onde mais é melhor. A leitura ideal é esforço baixo com cobertura alta.
O segundo gráfico conta a história real. O Next tem o maior esforço de setup, mas entrega cobertura alta de segurança quando bem feito, graças ao sliding window e ao edge. O Nuxt tem o menor esforço, com cobertura boa, desde que o storage seja resolvido. Express e Nest equilibram esforço baixo com cobertura alta, e o Nest lidera em cobertura pelos múltiplos limites simultâneos. Nenhum dos quatro é frágil, o que muda é o caminho até a proteção.
Métricas mensuráveis
Métrica | Next | Nuxt | Express | NestJS |
|---|---|---|---|---|
Linhas escritas pelo dev | 59 | 22 (módulo) | 35 | 37 |
Arquivos que você cria | 3 | 1 (config) | 1 | 2 |
Solução oficial ou nativa | Nenhuma | Módulo da comunidade | Lib madura | Módulo oficial |
Dependências de rate limit | 2 | 1 | 2 | 2 |
Facilidade e abordagem
Critério | Next | Nuxt | Express | NestJS |
|---|---|---|---|---|
Estilo | Middleware edge manual | Configuração | Middleware imperativo | Guard declarativo |
Limite por rota | Código na route handler | routeRules | Aplicar middleware | Decorator @Throttle |
Extração de IP | Manual | Automática no módulo | Automática (trust proxy) | Automática |
Múltiplos limites simultâneos | Manual | Manual | Manual | Nativo (nomeados) |
Segurança na prática
Critério | Next | Nuxt | Express | NestJS |
|---|---|---|---|---|
Algoritmo padrão | Sliding window | Token bucket | Fixed window | Fixed window |
Pronto para multi-instância | Sim, Redis obrigatório | Exige Redis no Nitro | Com Redis store | Com Redis adapter |
Risco de IP forjado | Médio, leitura manual | Baixo no módulo | Baixo se trust proxy ok | Baixo |
Headers padronizados | Manual | Sim, via opção | draft-8 | Configurável |
Qual escolher para proteger a sua API
As quatro pilhas fazem rate limiting de produção em 2026. O que muda é o caminho até a proteção e quanto o framework guia você. Se a sua API mora num back-end dedicado, o cenário mais comum, Express e Nest entregam soluções maduras com pouco esforço, e o Nest se destaca pela natureza declarativa e pelos múltiplos limites simultâneos sem gambiarra. São a escolha natural de quem separa o back-end, que é o que a maior parte da comunidade faz.
Se a sua API vive dentro de um Next, o rate limiting fica por sua conta e o ambiente serverless torna o Redis externo praticamente obrigatório. Funciona muito bem e ainda roda no edge, mas exige entender extração de IP e estado externo. A armadilha a evitar é confiar em contador de memória, que não sobrevive ao deploy.
No Nuxt, o nuxt-security oferece o caminho mais curto para ligar a proteção por configuração, ideal para quem quer segurança decente com atrito mínimo. A ressalva única é o storage: a conveniência esconde a necessidade de Redis em produção distribuída, e ignorar isso dá uma falsa sensação de proteção.
Independente da pilha, os princípios que de fato protegem contra abuso são os mesmos: contador compartilhado em Redis para valer no conjunto, identificação por usuário quando possível em vez de só IP, limite estrito nas rotas sensíveis como login, e resposta 429 com headers padronizados. E vale um lembrete de segurança de cadeia de suprimentos: rate limiting depende de poucas bibliotecas muito sensíveis, então fixe versões, audite dependências e desconfie de atualizações não verificadas, porque um limitador comprometido é uma porta dos fundos com vista privilegiada para o seu tráfego.