Skip to content

Rate limiting

The Rate Limiting binding gives a Worker a counter it can consult on every request. Pass a key, such as an IP, a user id, or an API token, and get back whether that key is within its budget. There is no backing cloud resource to deploy; the limit configuration lives on the binding itself and ships with the Worker.

yield* Cloudflare.RateLimit(name, props) in the Worker’s Construction phase and provide RateLimitBinding as a layer:

src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
export default Cloudflare.Worker(
"ThrottledWorker",
{ main: import.meta.url },
Effect.gen(function* () {
const throttle = yield* Cloudflare.RateLimit("THROTTLE", {
namespaceId: 1001,
simple: { limit: 10, period: 60 },
});
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const ip = request.headers["cf-connecting-ip"] ?? "unknown";
const { success } = yield* throttle
.limit({ key: ip })
.pipe(Effect.orDie);
return success
? HttpServerResponse.text("ok")
: HttpServerResponse.text("rate limited", { status: 429 });
}),
};
}).pipe(Effect.provide(Cloudflare.Workers.RateLimitBinding)),
);

simple.limit is the number of requests allowed per simple.period seconds. The period must be 10 or 60. namespaceId is a number or string that uniquely identifies this limit configuration within the account; reuse the same id to share one budget across Workers, use distinct ids for independent limits.

Every key passed to limit gets its own budget. With limit: 2, period: 10, two calls for "alice" succeed and the third is denied, while "bob" still has a full budget:

const a1 = yield* throttle.limit({ key: "alice" }); // { success: true }
const a2 = yield* throttle.limit({ key: "alice" }); // { success: true }
const a3 = yield* throttle.limit({ key: "alice" }); // { success: false }
const b1 = yield* throttle.limit({ key: "bob" }); // { success: true }

Counting is approximate and local to the Cloudflare location serving the request. The binding is built for cheap, fast abuse protection, not globally consistent quotas. For exact global counting, put the counter in a Durable Object.

limit fails with a typed RateLimitError if the runtime call itself blows up (as opposed to the key being over budget, which is the success: false case). It lives in the Effect error channel like any other tagged error:

const { success } = yield* throttle.limit({ key }).pipe(
// fail open: allow the request if the limiter itself errors
Effect.catchTag("RateLimitError", () =>
Effect.succeed({ success: true }),
),
);

Workers whose main module exports a plain async fetch handler declare the limit on env instead. InferEnv types it as Cloudflare’s native RateLimit binding:

alchemy.run.ts
export const Worker = Cloudflare.Worker("Worker", {
main: "./src/worker.ts",
env: {
THROTTLE: Cloudflare.RateLimit("THROTTLE", {
namespaceId: 1001,
simple: { limit: 10, period: 60 },
}),
},
});
export type WorkerEnv = Cloudflare.InferEnv<typeof Worker>;
src/worker.ts
import type { WorkerEnv } from "../alchemy.run.ts";
export default {
async fetch(req: Request, env: WorkerEnv) {
const { success } = await env.THROTTLE.limit({
key: req.headers.get("cf-connecting-ip") ?? "unknown",
});
return new Response(success ? "ok" : "rate limited", {
status: success ? 200 : 429,
});
},
};