Franco Seiler — Software StudioFranco Seiler — Software StudioBootcampYouTube
← Volver al blog

Rate limiting sin Redis: hasta dónde llega el estado en memoria

Casi todas las guías de rate limiting asumen Redis desde el minuto cero. El estado en memoria es imperfecto, pero para un formulario suele alcanzar.

Por Franco Seiler4 min de lecturaRead in English
Panel de monitoreo de servidores

Buscá "rate limiting en Next.js" y todos los resultados sacan Redis en el primer párrafo. Redis es la respuesta correcta a escala, pero si estás protegiendo el formulario de contacto de un portfolio, levantar una base de datos para contar requests es mucha infraestructura para el problema que tenés.

Esta es la versión en memoria, y un relato honesto de dónde se rompe.

La implementación

Un Map que guarda marcas de tiempo por IP:

// lib/rateLimit.ts
type Entry = { count: number; resetAt: number };
const store = new Map<string, Entry>();

export function rateLimit(
  key: string,
  limit = 5,
  windowMs = 60_000
): { ok: boolean; remaining: number } {
  const now = Date.now();
  const entry = store.get(key);

  if (!entry || now > entry.resetAt) {
    store.set(key, { count: 1, resetAt: now + windowMs });
    return { ok: true, remaining: limit - 1 };
  }

  if (entry.count >= limit) {
    return { ok: false, remaining: 0 };
  }

  entry.count++;
  return { ok: true, remaining: limit - entry.count };
}

Usado en un route handler:

export async function POST(request: Request) {
  const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? 'unknown';
  const { ok } = rateLimit(`contact:${ip}`, 5, 60_000);

  if (!ok) {
    return Response.json(
      { error: 'Demasiadas solicitudes' },
      { status: 429, headers: { 'Retry-After': '60' } }
    );
  }

  // ... procesar el formulario
}

Treinta líneas, sin dependencias, sin infraestructura.

La fuga que nadie menciona

Ese Map crece para siempre. Cada IP nueva agrega una entrada y nada la saca. En un servidor de larga vida eso es una fuga de memoria.

La solución es limpiar las entradas vencidas cada tanto, no con un temporizador sino aprovechando las llamadas normales:

let lastCleanup = Date.now();

function cleanup(now: number) {
  // Como mucho una vez por minuto, y recorriendo solo entradas ya muertas.
  if (now - lastCleanup < 60_000) return;
  lastCleanup = now;
  for (const [key, entry] of store) {
    if (now > entry.resetAt) store.delete(key);
  }
}

Llamás a cleanup(now) al principio de rateLimit. Un setInterval también funcionaría en un servidor tradicional, pero mantiene el proceso vivo y se porta mal en serverless.

Dónde se rompe de verdad

Dos limitaciones, y la segunda es la que decide si esto te alcanza.

No sobrevive a los reinicios. Deployás y todos los contadores vuelven a cero. Para protección contra abuso casi no importa: un atacante no puede forzar tus deploys.

En serverless, el límite es por instancia. Esta es la real. Vercel, Lambda y compañía corren varias instancias de tu función en paralelo, cada una con su propia memoria. Un límite de 5 requests por minuto con 4 instancias tibias es, en el peor caso, 20 requests por minuto.

Así que la forma honesta de describirlo: no es "5 por minuto", es "entre 5 y 5×N por minuto", donde N son las instancias que estén tibias en ese momento.

Cuándo eso está bien

Para la mayoría de los casos, lo está. Pensá contra qué te estás defendiendo:

Amenaza ¿Alcanza el estado en memoria?
Alguien spameando tu formulario a mano Sí, tranquilamente
Un bot básico pegándole a un endpoint público Sí: 20/min igual corta la avalancha
Fuerza bruta contra un login No, acá el multiplicador es peligroso
API que facturás por request No, necesitás conteos exactos
Ataque distribuido desde muchas IPs No, pero Redis tampoco; ahí va un WAF

El patrón: si pasarse del límite te cuesta plata o seguridad, necesitás estado compartido. Si solo significa unos mails de más, en memoria está bien.

Cuándo migrar

Pasate a Upstash o Redis cuando se cumpla alguna de estas: el límite protege autenticación, cobrás por request, necesitás contadores visibles entre instancias para monitoreo, o ya estás corriendo Redis para otra cosa —en ese punto no hay razón para no usarlo—.

La migración queda contenida si mantenés la misma firma:

// El lugar donde se llama no cambia, solo la implementación de atrás.
const { ok } = await rateLimit(`contact:${ip}`, 5, 60_000);

Escribila devolviendo una promesa desde el día uno, aunque por ahora sea síncrona. Así cambiar el backend no se propaga a todos los que la llaman.

Cierre

El rate limiting en memoria es una solución deliberadamente imperfecta, y saber exactamente en qué es imperfecta es lo que la vuelve una elección razonable. Frena el abuso accidental y los bots casuales con cero infraestructura. No te da una garantía dura, así que no lo pongas delante de nada donde cruzar el límite duela de verdad.

Si estás endureciendo un formulario público, mover el envío de mails al servidor es la otra mitad del mismo trabajo.

Recibí los artículos por mail

Un mail cuando publicamos algo nuevo. Sin spam, sin promociones, y te das de baja en un clic.

Rate limiting sin Redis: hasta dónde llega el estado en memoria | Franco Seiler