# Cara membatasi rate kiriman formulir

> Batasi rate kiriman formulir per pengunjung dengan store bersama, jawab 429 dengan Retry-After, dan hindari mengunci kantor yang berbagi satu IP.

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

Hitung kiriman per IP pengunjung di store yang dipakai bersama oleh setiap instance server, seperti Redis, izinkan lonjakan kecil, dan jawab `429` dengan header `Retry-After` saat hitungannya habis. Penghitung in-memory hanya berfungsi di satu server yang berjalan lama, tidak pernah di hosting serverless. Tentukan batasnya dari cara orang sungguhan memakai formulir, dan beri ruang untuk kantor dan acara di mana banyak orang berbagi satu IP.

## Apa yang berbeda dari rate limit

Rate limit tidak membedakan spam dari pesan asli. Ia menghentikan volume: skrip yang mengirim berulang-ulang, bot yang terus memutari formulir Anda, atau orang yang menekan tombol bertubi-tubi. Itu menjadikannya lapisan termurah, karena berjalan sebelum body dibaca, dan yang paling kasar, karena tidak bisa membedakan penyerang dari kantor yang sibuk. Tiga hal menentukan apakah ia membantu atau merugikan: di mana hitungan disimpan, berdasarkan apa Anda menghitung, dan seberapa cepat jatahnya kembali.

Diagram: Lonjakan kiriman dari satu pengunjung terhadap batas per pengunjung yang mulus

1. Seorang pengunjung mengirim 3 kiriman dalam 10 detik. Semuanya diterima.
2. Kiriman berikutnya ditolak dengan 429 dan header Retry-After yang menyebutkan berapa lama harus menunggu.
3. Setelah itu, satu kiriman lagi diizinkan setiap 12 detik.
4. Setelah satu menit penuh tanpa kiriman, seluruh jatah kembali.

Ini angka Standard milik Ikiform: 5 per menit, maksimal 3 per 10 detik. Pengisian ulang yang mulus berarti tidak ada yang harus menunggu satu jendela penuh untuk satu kiriman lagi.

## Di mana hitungan disimpan

| Store | Berfungsi di | Waspadai |
| --- | --- | --- |
| `Map` di memori | Satu server yang berjalan lama | Hilang saat restart. Setiap instance serverless punya map sendiri, jadi batas sebenarnya tidak diketahui |
| Redis, seperti Upstash | Apa saja, termasuk serverless dan edge | Satu panggilan jaringan per kiriman. Pilih region yang dekat dengan server Anda |
| Binding rate limiting Cloudflare Workers | Workers | Perkiraan dan dihitung per lokasi Cloudflare, hanya dengan periode 10 atau 60 detik |
| Reverse proxy, seperti `limit_req` nginx | Server di balik proxy Anda sendiri | Dihitung per proxy. Butuh IP client yang asli |

Algoritmanya kurang penting dibanding store-nya, tetapi membentuk apa yang dirasakan pengunjung. Fixed window di-reset setiap menit, jadi skrip bisa mengirim satu jatah penuh di akhir satu menit dan satu lagi di awal menit berikutnya. Sliding window memperhalus tepi itu dengan menimbang jendela sebelumnya. Token bucket, atau algoritma serupa yang dipakai Ikiform, mengisi ulang satu kiriman demi satu, sehingga orang yang salah ketik bisa mengirim lagi beberapa detik kemudian tanpa membuka pintu bagi lonjakan. Untuk formulir kontak, semuanya bisa dipakai asalkan angkanya sesuai dengan cara orang memakainya.

Contoh di bawah memakai `@upstash/ratelimit` dari Upstash, karena bekerja sama persis di setiap framework dan di hosting serverless. Atur `UPSTASH_REDIS_REST_URL` dan `UPSTASH_REDIS_REST_TOKEN`, lalu simpan helper-nya sekali:

`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` adalah waktu Unix dalam milidetik, jadi helper mengubahnya menjadi detik bulat untuk `Retry-After`. Kiriman tanpa IP berbagi satu bucket, sehingga header yang hilang tidak menjadi tiket gratis.

## Terapkan di framework Anda

**HTML**

Formulir HTML biasa tidak punya server untuk menghitung apa pun. Kirim ke Ikiform dan pilih preset di formulir; pengunjung yang melewati batas melihat halaman perlambat singkat, bukan 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**

Periksa batas di bagian paling atas server action, sebelum pekerjaan lain.

`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` memberi IP pengunjung di halaman on-demand. `TOO_MANY_REQUESTS` dipetakan ke `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**

Atur status dan `Retry-After` dari server function, agar pemanggil `fetch` bisa membacanya.

`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()` memungkinkan action mengembalikan `429` dengan header bersama pesan untuk formulir.

`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()` membaca IP dari adapter Anda. `fail(429)` mempertahankan formulir di halaman beserta pesannya. Simpan helper sebagai `src/lib/limit.ts`; SvelteKit 3 mengimpornya lewat alias `#lib` di `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` dengan `xForwardedFor` membaca IP client di balik proxy. Simpan helper di `server/utils` agar diimpor otomatis.

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

Single-page app tidak bisa menerapkan batas, tetapi harus menghormatinya. Baca `Retry-After` dan hitung mundur alih-alih membiarkan orang menekan tombol lagi. `429 quota_exceeded` tidak punya `Retry-After`, jadi jatuh ke pesan 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**

Penanganan yang sama di komponen Vue: baca `Retry-After`, nonaktifkan tombol, dan hitung mundur di 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>
```

Di Cloudflare Workers, binding bawaan adalah opsi termurah:

`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 mendokumentasikan binding itu sebagai perkiraan dan dihitung terpisah di setiap lokasi, jadi serangan yang tersebar bisa melampauinya. Untuk batas yang tepat di semua lokasi, Cloudflare merekomendasikan Durable Object. Di balik nginx Anda sendiri, `limit_req` melakukan penghitungan sebelum aplikasi Anda melihat kiriman:

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

## Berdasarkan apa menghitung

- **IP dan formulir bersamaan.** Pilihan yang umum. Satu pengunjung tidak bisa membanjiri formulir ini, dan memakai formulir lain di situs Anda tidak menghabiskan jatah yang sama.
- **Banyak orang, satu IP.** Kantor, sekolah, operator seluler, dan Wi-Fi konferensi menempatkan banyak orang di balik satu alamat. Buat batas per IP cukup longgar untuk mereka, atau naikkan pada hari acara.
- **IP client yang asli.** Di balik proxy atau CDN, baca header yang diatur hosting Anda dan hanya percayai yang itu. Nilai `X-Forwarded-For` mentah bisa diatur oleh client.
- **IP server Anda sendiri.** Jika server Anda meneruskan kiriman ke layanan lain, setiap pengunjung berbagi IP server Anda di sana. Batasi di sisi Anda, berdasarkan IP pengunjung, sebelum meneruskan.

## Rate limit Ikiform

Ikiform menghitung setiap kiriman secara tepat, per IP pengunjung dan formulir, sebelum membaca body, dengan pengisian ulang yang mulus alih-alih jendela tetap. Setiap formulir memilih preset di **Rate limit**. Proteksi flood 600 per menit per IP di semua formulir berjalan sebelum semuanya.

| Preset | Per pengunjung | Cocok untuk |
| --- | --- | --- |
| Standard | 5 per menit, maksimal 3 per 10 detik | Sebagian besar formulir |
| Relaxed | 20 per menit | Acara dengan Wi-Fi bersama, formulir di balik login |
| Strict | 2 per 10 menit, satu lagi setiap 5 menit | Formulir kontak dan penjualan |
| Custom | 1 hingga 1.000 per 10 detik, menit, 10 menit, jam, atau hari | Satu entri per orang, kios |

Seluruh formulir juga menerima 300 per menit dari semua pengunjung kecuali Anda mengatur batas Custom. Melewati batas, kiriman browser melihat halaman perlambat dan `fetch` mendapat `429 rate_limited` dengan `Retry-After`, yang diekspos Ikiform lewat CORS agar kode di atas bisa membacanya. [Dokumentasi rate limit](https://ikiform.com/id/docs/rate-limits.md#refill) menjelaskan pengisian ulangnya.

> Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati **Allowed domains** dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. Pilih [rate limit](https://ikiform.com/id/docs/rate-limits.md) Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan `IKIFORM_SECRET_KEY` di environment variable khusus server.

## Pastikan berfungsi

1. Kirim formulir beberapa kali berturut-turut. Setelah melewati batas Anda mendapat `429` dengan header `Retry-After`, atau halaman perlambat dari Ikiform.
2. Tunggu selama jumlah detik di `Retry-After` lalu kirim lagi. Kiriman diterima.
3. Kirim dari jaringan kedua, seperti ponsel Anda tanpa Wi-Fi. Jaringan itu punya jatahnya sendiri.
4. Jika server Anda meneruskan kiriman, kirim dari dua perangkat dengan cepat dan pastikan keduanya tidak berbagi hitungan.

## Hal yang sering menjebak

- **Kiriman yang ditolak tidak boleh memakan jatah.** Hitung sebelum pekerjaan, dan jangan biarkan kiriman yang ditolak memperpanjang jendela, atau pengunjung yang gugup akan mengunci dirinya sendiri.
- **Jangan batasi pemuatan halaman.** Hitung kiriman, bukan tampilan formulir, atau satu orang yang memuat ulang akan menghabiskan jatah kirimnya.
- **`429 rate_limited`** Satu pengunjung mengirim melebihi rate limit formulir. Standard mengizinkan 5 per menit per pengunjung. Tunggu sesuai `Retry-After`, atau pilih Relaxed atau Custom untuk formulir yang mengirim dari satu IP bersama.
- **IPv6.** Satu orang bisa memegang banyak alamat IPv6. Untuk batas yang ketat, hitung berdasarkan prefix `/64`, bukan alamat lengkap.

## Pertanyaan

### Berapa banyak kiriman formulir per menit yang sebaiknya saya izinkan?

Untuk formulir kontak, beberapa per pengunjung sudah lebih dari cukup: preset Strict milik Ikiform mengizinkan 2 per 10 menit. Formulir pendaftaran dan acara yang dipakai dari jaringan bersama butuh lebih, sekitar 20 per menit.

### Bisakah saya membuat rate limit in-memory di Vercel atau Netlify?

Tidak dengan andal. Setiap instance serverless punya memorinya sendiri dan instance datang dan pergi, jadi hitungan di-reset secara acak. Pakai store bersama seperti Redis.

### Apa yang sebaiknya ditampilkan formulir yang terkena rate limit?

Pesan sederhana yang menyebutkan berapa lama harus menunggu, diambil dari header Retry-After, dengan tombol kirim dinonaktifkan sampai saat itu. Jangan hapus apa yang sudah diketik orang itu.

### Apakah Ikiform membatasi rate formulir secara default?

Ya. Setiap formulir dimulai dengan preset Standard, 5 per menit per pengunjung, dan Anda bisa beralih ke Relaxed, Strict, atau Custom per formulir.

## Panduan lainnya

- [Hentikan spam formulir kontak](https://ikiform.com/id/guides/spam-protection.md): Hentikan spam formulir kontak dengan lapisan yang tidak membebani orang sungguhan: honeypot, waktu pengisian, skor konten, rate limit, dan pemeriksaan manusia hanya bila perlu.
- [Tanpa captcha](https://ikiform.com/id/guides/spam-protection/without-captcha.md): Hentikan sebagian besar spam formulir tanpa captcha sama sekali: honeypot, waktu render bertanda tangan, skor konten, dan rate limit, digabungkan alih-alih dipakai sendiri-sendiri.
- [Field honeypot](https://ikiform.com/id/guides/spam-protection/honeypot.md): Tambahkan honeypot ke formulir apa pun yang menangkap bot tanpa mengganggu screen reader, keyboard, atau autofill, dengan pemeriksaan server untuk setiap stack.
- [Situs statis](https://ikiform.com/id/guides/spam-protection/static-site.md): Hentikan spam di formulir situs statis meskipun situs itu tidak bisa menyimpan secret atau menghitung kiriman, dengan partial honeypot, skrip yang aman, dan pemeriksaan sisi server.
- [Cloudflare Turnstile](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md): Tambahkan Cloudflare Turnstile ke formulir HTML biasa: widget, siteverify di sisi server, key uji, dan cara menjalankannya tanpa key atau server sendiri.
- [Alternatif reCAPTCHA](https://ikiform.com/id/guides/spam-protection/recaptcha-alternatives.md): Bandingkan alternatif reCAPTCHA untuk formulir, dari Turnstile dan hCaptcha hingga proof-of-work dan tanpa captcha sama sekali, lalu beralih dengan perubahan kecil yang aman.
