# How to rate limit form submissions

> Rate limit form submissions per visitor with a shared store, answer 429 with Retry-After, and avoid locking out offices that share one IP.

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

Count posts per visitor IP in a store every server instance shares, such as Redis, allow a small burst, and answer `429` with a `Retry-After` header when the count runs out. An in-memory counter only works on one long-running server, never on serverless hosts. Set the limit from how real people use the form, and leave room for offices and events where many people share one IP.

## What's different about rate limits

A rate limit doesn't know spam from mail. It stops volume: a script posting in a loop, a bot cycling through your form, or a person hammering the button. That makes it the cheapest layer, since it runs before you read the body, and the bluntest, since it can't tell an attacker from a busy office. Three things decide whether it helps or hurts: where the count lives, what you count by, and how fast the allowance comes back.

Diagram: A burst of posts from one visitor against a smooth per-visitor limit

1. A visitor sends 3 posts within 10 seconds. All are accepted.
2. The next one is refused with 429 and a Retry-After header saying how long to wait.
3. After that, one more post is allowed every 12 seconds.
4. After a full quiet minute, the whole allowance is back.

These are Ikiform's Standard numbers: 5 a minute, at most 3 every 10 seconds. A smooth refill means nobody waits a whole window for one more post.

## Where the count lives

| Store | Works on | Watch out for |
| --- | --- | --- |
| A `Map` in memory | One long-running server | Lost on restart. Each serverless instance has its own map, so the real limit is unknown |
| Redis, such as Upstash | Anything, including serverless and edge | One network call per post. Pick a region near your server |
| Cloudflare Workers rate limiting binding | Workers | Approximate and counted per Cloudflare location, with 10 or 60 second periods only |
| A reverse proxy, such as nginx `limit_req` | Servers behind your own proxy | Counts per proxy. Needs the real client IP |

The algorithm matters less than the store, but it shapes what visitors feel. A fixed window resets on the minute, so a script can send a full allowance at the end of one minute and another at the start of the next. A sliding window smooths that edge by weighing the previous window. A token bucket, or the similar algorithm Ikiform uses, refills one post at a time, which lets a person who made a typo send again a few seconds later without opening the door to a burst. For a contact form, any of them works if the numbers fit how people use it.

The examples below use Upstash's `@upstash/ratelimit`, because it works the same in every framework and on serverless hosts. Set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`, and save the helper once:

`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` is a Unix time in milliseconds, so the helper turns it into whole seconds for `Retry-After`. Posts with no IP share one bucket, which keeps a missing header from becoming a free pass.

## Apply it in your framework

**HTML**

A plain HTML form has no server to count anything. Post it to Ikiform and pick a preset on the form; a visitor over the limit sees a short slow-down page instead of an 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**

Check the limit at the top of the server action, before any other work.

`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` gives the visitor's IP on an on-demand page. `TOO_MANY_REQUESTS` maps to a `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**

Set the status and `Retry-After` from the server function, so `fetch` callers can read them.

`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()` lets the action return a `429` with headers alongside the message for the form.

`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()` reads the IP from your adapter. `fail(429)` keeps the form on the page with the message. Save the helper as `src/lib/limit.ts`; SvelteKit 3 imports it through the `#lib` alias in `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` with `xForwardedFor` reads the client IP behind a proxy. Save the helper in `server/utils` to auto-import it.

`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**

A single-page app can't enforce a limit, but it should respect one. Read `Retry-After` and count down instead of letting people press the button again. A `429 quota_exceeded` has no `Retry-After`, so it falls through to the error message.

`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**

The same handling in a Vue component: read `Retry-After`, disable the button, and count down in a 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>
```

On Cloudflare Workers, the built-in binding is the cheapest option:

`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 documents that binding as approximate and counted separately in each location, so a spread-out attack can exceed it. For an exact limit across locations, Cloudflare recommends a Durable Object. Behind your own nginx, `limit_req` does the counting before your app sees the post:

`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;
  }
}
```

## What to count by

- **IP and form together.** The usual choice. One visitor can't flood this form, and using another form on your site doesn't spend the same allowance.
- **Many people, one IP.** Offices, schools, mobile carriers and conference Wi-Fi put many people behind one address. Keep the per-IP limit loose enough for them, or raise it for event days.
- **The real client IP.** Behind a proxy or CDN, read the header your host sets and trust only that one. A raw `X-Forwarded-For` value can be set by the client.
- **Your own server's IP.** If your server forwards posts to another service, every visitor shares your server's IP there. Limit on your side, by the visitor's IP, before forwarding.

## Ikiform's rate limits

Ikiform counts every post exactly, per visitor IP and form, before it reads the body, with a smooth refill rather than fixed windows. Each form picks a preset under **Rate limit**. A flood guard of 600 a minute per IP across all forms runs before any of them.

| Preset | Per visitor | Suits |
| --- | --- | --- |
| Standard | 5 a minute, at most 3 every 10 seconds | Most forms |
| Relaxed | 20 a minute | Events on shared Wi-Fi, forms behind a login |
| Strict | 2 every 10 minutes, one more every 5 minutes | Contact and sales forms |
| Custom | 1 to 1,000 per 10 seconds, minute, 10 minutes, hour or day | One entry per person, kiosks |

The whole form also takes 300 a minute across all visitors unless you set a Custom cap. Over the limit, a browser post sees a slow-down page and `fetch` gets `429 rate_limited` with `Retry-After`, which Ikiform exposes through CORS so the code above can read it. The [rate limits docs](https://ikiform.com/docs/rate-limits.md#refill) explain the refill.

> The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `IKIFORM_SECRET_KEY` in server-only environment variables.

## Check it works

1. Submit the form several times in a row. Past the limit you get `429` with a `Retry-After` header, or Ikiform's slow-down page.
2. Wait the number of seconds in `Retry-After` and submit again. It is accepted.
3. Submit from a second network, such as your phone off Wi-Fi. It has its own allowance.
4. If your server forwards posts, submit from two devices quickly and check neither shares the other's count.

## Gotchas

- **Refused posts shouldn't cost allowance.** Count before the work, and don't let a refused post push the window further out, or a nervous visitor locks themselves out.
- **Don't limit the page load.** Count submissions, not views of the form, or one person reloading uses up their sends.
- **`429 rate_limited`** One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for `Retry-After`, or choose Relaxed or Custom for forms that post from one shared IP.
- **IPv6.** One person can hold many IPv6 addresses. For a hard limit, count by the `/64` prefix rather than the full address.

## Questions

### How many form submissions per minute should I allow?

For a contact form, a handful per visitor is plenty: Ikiform's Strict preset allows 2 every 10 minutes. Signup and event forms used from shared networks need more, around 20 a minute.

### Can I rate limit in memory on Vercel or Netlify?

Not reliably. Each serverless instance has its own memory and instances come and go, so the count resets at random. Use a shared store such as Redis.

### What should a rate-limited form show?

A plain message saying how long to wait, taken from the Retry-After header, with the submit button disabled until then. Don't clear what the person typed.

### Does Ikiform rate limit forms by default?

Yes. Every form starts on the Standard preset, 5 a minute per visitor, and you can switch to Relaxed, Strict or Custom per form.

## More guides

- [Stop contact form spam](https://ikiform.com/guides/spam-protection.md): Stop contact form spam with layers that cost real people nothing: a honeypot, timing, content scoring, rate limits and a human check only where needed.
- [Without a captcha](https://ikiform.com/guides/spam-protection/without-captcha.md): Stop most form spam with no captcha at all: a honeypot, a signed render time, a content score and a rate limit, added together instead of used alone.
- [Honeypot field](https://ikiform.com/guides/spam-protection/honeypot.md): Add a honeypot to any form that catches bots without tripping screen readers, keyboards or autofill, with the server check for every stack.
- [Static sites](https://ikiform.com/guides/spam-protection/static-site.md): Stop spam on a static site form even though it can't keep a secret or count posts, with a honeypot partial, a safe script and server-side checks.
- [Cloudflare Turnstile](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md): Add Cloudflare Turnstile to a plain HTML form: the widget, server-side siteverify, test keys, and a way to run it with no keys or server of your own.
- [reCAPTCHA alternatives](https://ikiform.com/guides/spam-protection/recaptcha-alternatives.md): Compare reCAPTCHA alternatives for forms, from Turnstile and hCaptcha to proof-of-work and no captcha at all, then switch with a small, safe change.
