# Cómo limitar la frecuencia de envíos de un formulario

> Limita los envíos de un formulario por visitante con un almacén compartido, responde 429 con Retry-After y no bloquees oficinas que comparten una IP.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/spam-protection/rate-limiting

Cuenta los envíos por IP de visitante en un almacén que compartan todas las instancias del servidor, como Redis, permite una pequeña ráfaga y responde `429` con una cabecera `Retry-After` cuando se agote el cupo. Un contador en memoria solo funciona en un único servidor de larga duración, nunca en hosts serverless. Fija el límite según cómo usan el formulario las personas reales, y deja margen para oficinas y eventos donde mucha gente comparte una IP.

## Qué tienen de especial los límites de frecuencia

Un límite de frecuencia no distingue el spam del correo legítimo. Frena el volumen: un script que envía en bucle, un bot que recorre tu formulario o una persona que machaca el botón. Eso lo convierte en la capa más barata, porque se ejecuta antes de leer el cuerpo, y en la más tosca, porque no distingue a un atacante de una oficina con mucho movimiento. Tres cosas deciden si ayuda o estorba: dónde vive el conteo, por qué cuentas y lo rápido que se recupera el cupo.

Diagrama: Una ráfaga de envíos de un visitante frente a un límite por visitante con recarga gradual

1. Un visitante manda 3 envíos en 10 segundos. Todos se aceptan.
2. El siguiente se rechaza con 429 y una cabecera Retry-After que indica cuánto esperar.
3. Después, se permite un envío más cada 12 segundos.
4. Tras un minuto completo sin actividad, el cupo entero vuelve.

Son los números Standard de Ikiform: 5 por minuto, como máximo 3 cada 10 segundos. Una recarga gradual hace que nadie espere una ventana entera para un envío más.

## Dónde vive el conteo

| Almacén | Funciona en | Cuidado con |
| --- | --- | --- |
| Un `Map` en memoria | Un único servidor de larga duración | Se pierde al reiniciar. Cada instancia serverless tiene su propio map, así que el límite real es desconocido |
| Redis, como Upstash | Cualquier cosa, incluidos serverless y edge | Una llamada de red por envío. Elige una región cercana a tu servidor |
| Binding de rate limiting de Cloudflare Workers | Workers | Aproximado y contado por ubicación de Cloudflare, solo con periodos de 10 o 60 segundos |
| Un proxy inverso, como `limit_req` de nginx | Servidores detrás de tu propio proxy | Cuenta por proxy. Necesita la IP real del cliente |

El algoritmo importa menos que el almacén, pero define lo que notan los visitantes. Una ventana fija se reinicia cada minuto, así que un script puede mandar un cupo completo al final de un minuto y otro al principio del siguiente. Una ventana deslizante suaviza ese borde ponderando la ventana anterior. Un token bucket, o el algoritmo similar que usa Ikiform, recarga un envío cada vez, lo que permite a alguien que cometió una errata volver a enviar unos segundos después sin abrir la puerta a una ráfaga. Para un formulario de contacto, cualquiera sirve si los números encajan con cómo lo usa la gente.

Los ejemplos de abajo usan `@upstash/ratelimit` de Upstash, porque funciona igual en todos los frameworks y en hosts serverless. Define `UPSTASH_REDIS_REST_URL` y `UPSTASH_REDIS_REST_TOKEN`, y guarda el helper una vez:

`lib/limit.ts`:

```ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const contact = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  prefix: "contact",
});

export async function allow(ip: string | null | undefined) {
  const { success, reset } = await contact.limit(ip || "no-ip");
  return { ok: success, retryAfter: Math.max(1, Math.ceil((reset - Date.now()) / 1000)) };
}
```

`reset` es un tiempo Unix en milisegundos, así que el helper lo convierte en segundos enteros para `Retry-After`. Los envíos sin IP comparten un mismo bucket, lo que evita que una cabecera ausente se convierta en vía libre.

## Aplícalo en tu framework

**HTML**

Un formulario HTML básico no tiene servidor que cuente nada. Envíalo a Ikiform y elige un preset en el formulario; un visitante que supera el límite ve una breve página que le pide ir más despacio en lugar de un error.

`contact.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>
  <button>Send</button>
</form>
```

**Next.js**

Comprueba el límite al principio de la server action, antes de cualquier otro trabajo.

`app/contact/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { allow } from "@/lib/limit";

export async function sendMessage(_previous: { message: string }, formData: FormData) {
  const forwarded = (await headers()).get("x-forwarded-for");
  const limit = await allow(forwarded?.split(",")[0]?.trim());
  if (!limit.ok) return { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
  await saveMessage(Object.fromEntries(formData));
  return { message: "Thanks. We'll reply by email." };
}
```

**Astro**

`context.clientAddress` da la IP del visitante en una página bajo demanda. `TOO_MANY_REQUESTS` se traduce en un `429`.

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { z } from "astro/zod";
import { allow } from "../lib/limit";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({ email: z.email(), message: z.string().min(1) }),
    handler: async (input, context) => {
      const limit = await allow(context.clientAddress);
      if (!limit.ok) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
      }
      await saveMessage(input);
      return { sent: true };
    },
  }),
};
```

**TanStack Start**

Pon el estado y `Retry-After` desde la server function, para que quien llame con `fetch` pueda leerlos.

`src/server/send-message.ts`:

```ts
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeader, setResponseHeaders, setResponseStatus } from "@tanstack/react-start/server";
import { allow } from "../lib/limit";

export const sendMessage = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return { email: String(data.get("email") ?? ""), message: String(data.get("message") ?? "") };
  })
  .handler(async ({ data }) => {
    const limit = await allow(getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim());
    if (!limit.ok) {
      setResponseStatus(429);
      setResponseHeaders(new Headers({ "Retry-After": String(limit.retryAfter) }));
      return { ok: false, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
    }
    await saveMessage(data);
    return { ok: true, message: "Thanks. We'll reply by email." };
  });
```

**React Router**

`data()` permite a la action devolver un `429` con cabeceras junto al mensaje para el formulario.

`app/routes/contact.tsx`:

```ts
import { data } from "react-router";
import { allow } from "../lib/limit";
import type { Route } from "./+types/contact";

export async function action({ request }: Route.ActionArgs) {
  const limit = await allow(request.headers.get("x-forwarded-for")?.split(",")[0]?.trim());
  if (!limit.ok) {
    return data(
      { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` },
      { status: 429, headers: { "Retry-After": String(limit.retryAfter) } },
    );
  }
  await saveMessage(Object.fromEntries(await request.formData()));
  return { message: "Thanks. We'll reply by email." };
}
```

**SvelteKit**

`getClientAddress()` lee la IP de tu adapter. `fail(429)` mantiene el formulario en la página con el mensaje. Guarda el helper como `src/lib/limit.ts`; SvelteKit 3 lo importa con el alias `#lib` de `package.json`.

`src/routes/contact/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { allow } from "#lib/limit.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress }) => {
    const limit = await allow(getClientAddress());
    if (!limit.ok) return fail(429, { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
    await saveMessage(Object.fromEntries(await request.formData()));
    return { sent: true };
  },
} satisfies Actions;
```

**Nuxt**

`getRequestIP` con `xForwardedFor` lee la IP del cliente detrás de un proxy. Guarda el helper en `server/utils` para que se importe automáticamente.

`server/api/contact.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const limit = await allow(getRequestIP(event, { xForwardedFor: true }));
  if (!limit.ok) {
    setResponseHeader(event, "Retry-After", limit.retryAfter);
    throw createError({ status: 429, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
  }
  await saveMessage(await readBody(event));
  return { sent: true };
});
```

**React**

Una single-page app no puede imponer un límite, pero debería respetarlo. Lee `Retry-After` y muestra una cuenta atrás en lugar de dejar que la gente vuelva a pulsar el botón. Un `429 quota_exceeded` no tiene `Retry-After`, así que cae en el mensaje de error.

`contact.js`:

```js
const button = form.querySelector("button");
const status = form.querySelector("[role=status]");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(form),
  });
  const retryAfter = response.headers.get("Retry-After");
  if (response.status === 429 && retryAfter) {
    let wait = Number(retryAfter);
    button.disabled = true;
    const timer = setInterval(() => {
      status.textContent = `Too many messages. Try again in ${wait} seconds.`;
      wait -= 1;
      if (wait < 0) {
        clearInterval(timer);
        button.disabled = false;
        status.textContent = "";
      }
    }, 1000);
    return;
  }
  const result = await response.json();
  status.textContent = result.ok ? "Thanks. We'll reply by email." : result.error.message;
});
```

**Vue**

La misma gestión en un componente de Vue: lee `Retry-After`, desactiva el botón y lleva la cuenta atrás en un ref.

`src/components/ContactForm.vue`:

```vue
<script setup lang="ts">
import { onBeforeUnmount, ref } from "vue";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const wait = ref(0);
const notice = ref("");
let timer: ReturnType<typeof setInterval> | undefined;

function countDown(seconds: number) {
  wait.value = seconds;
  clearInterval(timer);
  timer = setInterval(() => {
    wait.value -= 1;
    if (wait.value <= 0) clearInterval(timer);
  }, 1000);
}

async function send(event: SubmitEvent) {
  notice.value = "";
  const response = await fetch(ENDPOINT, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(event.target as HTMLFormElement),
  });
  const retryAfter = Number(response.headers.get("Retry-After"));
  if (response.status === 429 && retryAfter > 0) {
    countDown(retryAfter);
    return;
  }
  const result = await response.json();
  notice.value = result.ok ? "Thanks. We'll reply by email." : result.error.message;
}

onBeforeUnmount(() => clearInterval(timer));
</script>

<template>
  <form :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="email">Email</label>
    <input id="email" name="email" type="email" required />
    <label for="message">Message</label>
    <textarea id="message" name="message" required />
    <p role="status">{{ wait > 0 ? `Too many messages. Try again in ${wait} seconds.` : notice }}</p>
    <button :disabled="wait > 0">Send</button>
  </form>
</template>
```

En Cloudflare Workers, el binding integrado es la opción más barata:

`wrangler.toml`:

```toml
[[ratelimits]]
name = "CONTACT_LIMIT"
namespace_id = "1001"

  [ratelimits.simple]
  limit = 5
  period = 60
```

`src/index.ts`:

```ts
const ip = request.headers.get("CF-Connecting-IP") ?? "no-ip";
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
if (!success) return new Response("Too many messages", { status: 429, headers: { "Retry-After": "60" } });
```

Cloudflare documenta ese binding como aproximado y contado por separado en cada ubicación, así que un ataque repartido puede superarlo. Para un límite exacto entre ubicaciones, Cloudflare recomienda un Durable Object. Detrás de tu propio nginx, `limit_req` hace el conteo antes de que tu app vea el envío:

`nginx.conf`:

```text
limit_req_zone $binary_remote_addr zone=contact:10m rate=5r/m;

server {
  location = /api/contact {
    limit_req zone=contact burst=3 nodelay;
    limit_req_status 429;
    proxy_pass http://127.0.0.1:3000;
  }
}
```

## Por qué contar

- **IP y formulario juntos.** La opción habitual. Un visitante no puede inundar este formulario, y usar otro formulario de tu sitio no gasta el mismo cupo.
- **Muchas personas, una IP.** Oficinas, colegios, operadoras móviles y el Wi-Fi de una conferencia ponen a mucha gente detrás de una misma dirección. Mantén el límite por IP lo bastante holgado para ellos, o súbelo los días de evento.
- **La IP real del cliente.** Detrás de un proxy o una CDN, lee la cabecera que pone tu host y confía solo en esa. El cliente puede fijar un valor de `X-Forwarded-For` en bruto.
- **La IP de tu propio servidor.** Si tu servidor reenvía envíos a otro servicio, allí todos los visitantes comparten la IP de tu servidor. Limita en tu lado, por la IP del visitante, antes de reenviar.

## Los límites de frecuencia de Ikiform

Ikiform cuenta cada envío con exactitud, por IP de visitante y formulario, antes de leer el cuerpo, con una recarga gradual en lugar de ventanas fijas. Cada formulario elige un preset en **Rate limit**. Antes que cualquiera de ellos se ejecuta una protección contra floods de 600 por minuto por IP en todos los formularios.

| Preset | Por visitante | Ideal para |
| --- | --- | --- |
| Standard | 5 por minuto, como máximo 3 cada 10 segundos | La mayoría de los formularios |
| Relaxed | 20 por minuto | Eventos con Wi-Fi compartido, formularios tras un inicio de sesión |
| Strict | 2 cada 10 minutos, uno más cada 5 minutos | Formularios de contacto y de ventas |
| Custom | De 1 a 1.000 por 10 segundos, minuto, 10 minutos, hora o día | Una entrada por persona, quioscos |

El formulario entero además admite 300 por minuto entre todos los visitantes, salvo que fijes un tope Custom. Por encima del límite, un envío del navegador ve una página que pide ir más despacio y `fetch` recibe `429 rate_limited` con `Retry-After`, que Ikiform expone por CORS para que el código de arriba pueda leerlo. La [documentación de límites de frecuencia](https://ikiform.com/es/docs/rate-limits.md#refill) explica la recarga.

> Las versiones de servidor envían con una clave secreta, así que Ikiform se salta **Allowed domains** y su verificación humana. Todos los envíos llegan entonces desde la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el [límite de frecuencia](https://ikiform.com/es/docs/rate-limits.md) Relaxed o Custom para un formulario que envías a través de un servidor, y guarda `IKIFORM_SECRET_KEY` en variables de entorno solo de servidor.

## Comprueba que funciona

1. Envía el formulario varias veces seguidas. Pasado el límite recibes `429` con una cabecera `Retry-After`, o la página de Ikiform que pide ir más despacio.
2. Espera los segundos que indique `Retry-After` y vuelve a enviar. Se acepta.
3. Envía desde una segunda red, como tu móvil sin Wi-Fi. Tiene su propio cupo.
4. Si tu servidor reenvía los envíos, envía rápido desde dos dispositivos y comprueba que ninguno comparte el conteo del otro.

## Trampas habituales

- **Los envíos rechazados no deberían gastar cupo.** Cuenta antes del trabajo, y no dejes que un envío rechazado aleje más la ventana, o un visitante nervioso se bloqueará a sí mismo.
- **No limites la carga de la página.** Cuenta envíos, no visitas al formulario, o una persona que recarga gastará sus envíos.
- **`429 rate_limited`** Un visitante envió más de lo que permite el límite de frecuencia del formulario. Standard permite 5 por minuto por visitante. Espera lo que indique `Retry-After`, o elige Relaxed o Custom para formularios que envían desde una IP compartida.
- **IPv6.** Una persona puede tener muchas direcciones IPv6. Para un límite estricto, cuenta por el prefijo `/64` en lugar de la dirección completa.

## Preguntas

### ¿Cuántos envíos de formulario por minuto debo permitir?

Para un formulario de contacto, unos pocos por visitante bastan: el preset Strict de Ikiform permite 2 cada 10 minutos. Los formularios de registro y de eventos usados desde redes compartidas necesitan más, unos 20 por minuto.

### ¿Puedo limitar la frecuencia en memoria en Vercel o Netlify?

No de forma fiable. Cada instancia serverless tiene su propia memoria y las instancias van y vienen, así que el conteo se reinicia al azar. Usa un almacén compartido como Redis.

### ¿Qué debe mostrar un formulario con límite de frecuencia?

Un mensaje sencillo que diga cuánto esperar, tomado de la cabecera Retry-After, con el botón de envío desactivado hasta entonces. No borres lo que la persona escribió.

### ¿Ikiform limita la frecuencia de los formularios por defecto?

Sí. Cada formulario empieza con el preset Standard, 5 por minuto por visitante, y puedes cambiar a Relaxed, Strict o Custom en cada formulario.

## Más guías

- [Frena el spam en formularios de contacto](https://ikiform.com/es/guides/spam-protection.md): Frena el spam en tu formulario de contacto con capas que no cuestan nada a las personas reales: honeypot, tiempo, puntuación del contenido, límites de frecuencia y verificación humana solo donde haga falta.
- [Sin captcha](https://ikiform.com/es/guides/spam-protection/without-captcha.md): Frena casi todo el spam de tus formularios sin ningún captcha: honeypot, hora de renderizado firmada, puntuación del contenido y límite de frecuencia, sumados en lugar de usarlos por separado.
- [Campo honeypot](https://ikiform.com/es/guides/spam-protection/honeypot.md): Añade a cualquier formulario un honeypot que atrape bots sin molestar a lectores de pantalla, teclado ni autocompletado, con la comprobación en el servidor para cada stack.
- [Sitios estáticos](https://ikiform.com/es/guides/spam-protection/static-site.md): Frena el spam en el formulario de un sitio estático aunque no pueda guardar secretos ni contar envíos, con un partial de honeypot, un script seguro y comprobaciones en el servidor.
- [Cloudflare Turnstile](https://ikiform.com/es/guides/spam-protection/cloudflare-turnstile.md): Añade Cloudflare Turnstile a un formulario HTML básico: el widget, siteverify en el servidor, claves de prueba y una forma de usarlo sin claves ni servidor propios.
- [Alternativas a reCAPTCHA](https://ikiform.com/es/guides/spam-protection/recaptcha-alternatives.md): Compara alternativas a reCAPTCHA para formularios, de Turnstile y hCaptcha a proof-of-work o ningún captcha, y cambia con un ajuste pequeño y seguro.
