Rate limiting

How to add rate limiting to SvelteKit form actions

Rate limiting controls how many actions an identity can perform in a period. In SvelteKit, enforce the limit inside the form action with @arcjet/sveltekit v1: create a client with arcjet(), attach slidingWindow() or tokenBucket(), and call protect() on the RequestEvent. Use npx sv create and Svelte 5 $props(), and key authenticated actions on a user ID rather than IP alone.

7 min read
In short: Rate limiting controls how many actions an identity can perform in a period. In SvelteKit, enforce the limit inside the form action with @arcjet/sveltekit v1: create a client with arcjet(), attach slidingWindow() or tokenBucket(), and call protect() on the RequestEvent. Use npx sv create and Svelte 5 $props(), and key authenticated actions on a user ID rather than IP alone.

What is rate limiting?

Rate limiting is a technique that controls how many requests or actions an identity can perform in a given period. You use it to stop automated floods, keep resources fair, and protect form actions that create data or consume paid work.

The attacks that a form-level limit stops include:

  • Denial-of-service floods that overwhelm a write path.
  • Brute-force and password-spraying attempts against login or reset actions.
  • API abuse such as inbox stuffing, enumeration, and resource exhaustion.

A SvelteKit form action is a server function. That is the right place to enforce the limit: you have the RequestEvent, the form payload, and any session you load. This guide uses the Arcjet SvelteKit SDK (@arcjet/sveltekit v1). You create a client with arcjet(), add a slidingWindow() or tokenBucket() rule, and call protect() on the event.

Which rate limiting algorithm should you use?

Arcjet exposes three algorithms. Pick one for the resource you protect. The following table summarizes the trade-offs. For failure modes and distributed behavior, see the rate limiting guide.

AlgorithmWhat it countsBurst behaviorBest for form actions
Fixed windowEvents in a clock-aligned windowUp to 2× at the resetSimple internal quotas where a boundary spike is safe
Sliding window

Events in the last n seconds

No 2× boundary dumpPublic pages and login-style actions that need fairness
Token bucketTokens withdrawn per requestUp to the configured capacityWrites with a small burst, then a steady refill

All three rules take the same duration form: a number of seconds, or a string such as "15s", "10m", or "1h45m". slidingWindow({ interval, max }) and tokenBucket({ refillRate, interval, capacity }) name it interval; fixedWindow({ window, max }) names it window. Pick one form and keep a codebase consistent. Token bucket is the only algorithm that needs requested on protect().

This tutorial uses a sliding window on every page and a token bucket on the form action.

How do you scaffold a SvelteKit app?

Svelte compiles .svelte components to JavaScript at build time. SvelteKit adds routing, server-side rendering, load functions, and form actions. Current projects use Svelte 5 runes ($props(), {@render children()}) rather than export let and <slot>.

Create the app with the official Svelte CLI, not the retired npm create svelte@latest package:

Terminal window
npx sv create ratelimit
cd ratelimit

Choose the SvelteKit minimal template, TypeScript syntax, and the add-ons you want (Prettier and ESLint are enough). Install dependencies when the CLI offers, or run npm install. Start the dev server with npm run dev and open http://localhost:5173.

How do you install the Arcjet SvelteKit SDK?

@arcjet/sveltekit declares a Node.js range of >=22.21.0 <23 || >=24.5.0 and peers on Svelte 3, 4, or 5. It sets no @sveltejs/kit floor of its own, so any supported SvelteKit 2 release works; this tutorial is written against SvelteKit 2 with Svelte 5. The package is ESM only.

Terminal window
npm i @arcjet/sveltekit@1.10.0

Create .env (or .env.local if you already use that file) in the project root:

Terminal window
ARCJET_ENV=development
ARCJET_KEY=ajkey_your_key

Get the key from the Arcjet dashboard when you create a site. Set ARCJET_ENV=development so private and loopback addresses are allowed while you test locally. In production, Arcjet rejects those addresses so a misconfigured proxy cannot collapse every client onto one internal IP.

Create a single client in a server-only module. Files under src/lib/server/ are importable as $lib/server/... and cannot leak into the browser bundle.

src/lib/server/arcjet.ts
import { env } from "$env/dynamic/private";
import arcjet from "@arcjet/sveltekit";
export const aj = arcjet({
key: env.ARCJET_KEY!,
rules: [],
});

You add per-route rules with withRule() so each page can choose its algorithm without constructing a new client.

How do you apply a global sliding window?

src/hooks.server.ts runs on every request. Put a baseline sliding window here, then skip routes that call protect() themselves so you do not double-count.

import { slidingWindow } from "@arcjet/sveltekit";
import { aj } from "$lib/server/arcjet";
import { error, type RequestEvent } from "@sveltejs/kit";
const selfProtected = ["/form"];
export async function handle({
event,
resolve,
}: {
event: RequestEvent;
resolve: (event: RequestEvent) => Response | Promise<Response>;
}): Promise<Response> {
if (selfProtected.includes(event.url.pathname)) {
return resolve(event);
}
const decision = await aj
.withRule(
slidingWindow({
mode: "LIVE",
interval: 15,
max: 5,
}),
)
.protect(event);
if (decision.isDenied()) {
return error(429, "Too many requests");
}
return resolve(event);
}

protect(event) is the v1 entry point. It takes the SvelteKit RequestEvent and returns an ArcjetDecision. decision.isDenied() is the high-level conclusion. Use decision.reason.isRateLimit() when you need to distinguish a quota denial from Shield or bot rules you add later.

How do you rate limit a SvelteKit form action?

A +page.svelte file under src/routes is a page. A sibling +page.server.ts owns load and named actions. Form actions run on POST and return data through the page form prop.

Add a layout so you can move between the home page (global sliding window) and the form (token bucket). Svelte 5 layouts render children with {@render children()}:

<script lang="ts">
let { children } = $props();
</script>
<nav>
<a href="/">Home</a>
<a href="/form">Form</a>
</nav>
{@render children()}

Store messages in a server-only module so the action can append and load can read:

src/lib/server/database.ts
type Message = { text: string; completed: boolean };
const messageList: Message[] = [
{ text: "Learn how forms work", completed: false },
];
export function getMessages() {
return messageList;
}
export function addMessage(text: string) {
messageList.push({ text, completed: false });
}

The page uses Svelte 5 $props() (not export let) and posts to the named action addToList:

<script lang="ts">
import type { PageProps } from "./$types";
let { data, form }: PageProps = $props();
</script>
<h1>Form</h1>
<ul>
{#each data.messages as message}
<li><span>{message.text}</span></li>
{/each}
</ul>
<form method="POST" action="?/addToList">
<input
type="text"
name="message"
value={form?.message ?? ""}
autocomplete="off"
/>
<button type="submit">Add message</button>
</form>
{#if form?.missing}
<p>This field is required.</p>
{/if}
{#if form?.success}
<p>Added message.</p>
{/if}

Call protect() inside the action, not in load. A page refresh should not spend the write budget. Token bucket requires requested: the number of tokens this submission withdraws.

import { tokenBucket } from "@arcjet/sveltekit";
import { aj } from "$lib/server/arcjet";
import { error, fail, type RequestEvent } from "@sveltejs/kit";
import { addMessage, getMessages } from "$lib/server/database";
export async function load() {
return { messages: getMessages() };
}
export const actions = {
addToList: async (event: RequestEvent) => {
const decision = await aj
.withRule(
tokenBucket({
mode: "LIVE",
refillRate: 1,
interval: 10,
capacity: 3,
}),
)
.protect(event, { requested: 1 });
if (decision.isDenied()) {
return error(429, "Too many requests made to /form.");
}
const formData = await event.request.formData();
const message = String(formData.get("message") ?? "");
if (!message) {
return fail(400, { message, missing: true });
}
addMessage(message);
return { success: true };
},
};

This bucket holds 3 tokens and refills 1 token every 10 seconds. Three fast submits succeed. A fourth submit before a refill returns HTTP 429 and SvelteKit renders the error page. If you want the user to stay on the form, return fail(429, { rateLimited: true }) instead and render that flag next to the button.

How do you verify the limits?

Run npm run dev and use the two routes.

On http://localhost:5173/, the hook applies the sliding window. Six full-page loads inside 15 seconds trip the limit. The sixth response is a SvelteKit error page with status 429 and the body "Too many requests".

On http://localhost:5173/form, submit the form four times in a row. The first three append a list item and show "Added message." The fourth submit returns 429 with "Too many requests made to /form."

In the Arcjet dashboard, each request appears with an ALLOW or DENY conclusion, the rule that produced it, remaining capacity, reset time, and the fingerprint (IP by default). Use those rows to confirm you did not double-call protect() on /form.

What should you key the limit on?

The default characteristic is ip.src. That is acceptable for an anonymous form. Shared networks and rotating proxies make it a weak identity. When the user is logged in, add a custom characteristic such as userId and pass it to protect():

const decision = await aj
.withRule(
tokenBucket({
mode: "LIVE",
characteristics: ["userId"],
refillRate: 1,
interval: 10,
capacity: 3,
}),
)
.protect(event, {
userId: event.locals.user.id,
requested: 1,
});

Pair the limiter with bot detection on public forms if you also need to classify automated clients. Rate limiting caps volume. Bot detection names the client. You usually want both on signup, comment, and login actions.

Frequently asked questions

How do you rate limit a SvelteKit form action?

Create one arcjet() client in a server-only module, attach a tokenBucket() or slidingWindow() rule with withRule(), and call protect(event) inside the named action. Return HTTP 429 when decision.isDenied() and decision.reason.isRateLimit(). Do not spend the write budget in load().

Which algorithm should you use on a form?

Use a sliding window when you need fairness without a 2x reset spike. Use a token bucket when you want a small burst of submits and a steady refill. Use a fixed window only when a boundary spike cannot hurt the action. See the rate limiting guide for the full trade-offs.

What replaced `npm create svelte@latest`?

The official scaffolder is npx sv create. It sets up a SvelteKit project with Svelte 5. Choose the minimal template and TypeScript syntax, then install @arcjet/sveltekit.

Why can two `protect()` calls on the same request double-count?

Each protect() evaluates the rules and records usage. If hooks.server.ts and the form action both call protect() on /form, one submit can consume two slots. Skip self-protected routes in the hook.

Application security in your code

Protect your application with Arcjet

Get rate limits, bot detection, and attack blocking in your request handlers.