Cloudflare Worker Rate Limiting at the Edge
An edge worker runs in hundreds of locations, which is exactly what makes it a great volumetric shield and a poor exact counter. Choosing between approximate per-location counting and a coordinated Durable Object is the central decision here, and it extends the tier comparison from the edge and gateway enforcement guide into the specific mechanics of the Workers runtime.
The problem in concrete numbers
A client in Frankfurt reaches the Frankfurt point of presence. A second client on the same API key, on mobile data, reaches Amsterdam. If each location keeps its own counter, a limit of “100 per minute” is enforced per location, and a distributed client — or one behind a mobile carrier that hands out addresses across regions — can multiply its budget by the number of locations it touches. In practice a single browser session touches one or two, and a botnet touches dozens: the approximation is therefore tight where it does not matter and loose exactly where abuse happens.
The coordinated alternative sends every decision to one Durable Object instance, which lives in one location globally. A request that lands in Sydney and coordinates with an object in Frankfurt pays 250–300 ms of round trip — far more than the origin request it was trying to save.
Decision matrix
| Approach | Accuracy | Added latency | Cost model | Right for |
|---|---|---|---|---|
| In-isolate memory counter | Per isolate; resets on eviction | ~0 | Free | Cheap flood damping, per-IP |
Per-location cache (caches.default) |
Per location | < 1 ms | Free | Coarse abuse control |
| KV read/write | Eventually consistent, seconds of lag | ~5–20 ms | Per operation | Config and allowlists, not counters |
| Durable Object | Exact, globally serialised | 5–300 ms by distance | Per request + duration | Contract limits, small key counts |
| Platform rate limiting binding | Exact per location, managed | ~0 | Included | Standard per-key limits without state code |
| Origin limiter behind the Worker | Exact | Origin round trip | Your infrastructure | Everything that touches account state |
An in-isolate counter deserves a warning: Workers isolates are created and destroyed constantly, so the counter’s lifetime is unpredictable — it may last seconds. That is acceptable for damping a flood within one burst, and useless as a limit anyone depends on.
Step-by-step implementation
// worker.js — per-location approximate limiter with a correct 429 contract.
const WINDOW_SECONDS = 60;
const EDGE_LIMIT = 600; // ~6x the origin's published 100/min
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === "/healthz") return fetch(request); // never limit probes
const key = request.headers.get("X-API-Key")
?? request.headers.get("CF-Connecting-IP")
?? "anonymous";
const window = Math.floor(Date.now() / 1000 / WINDOW_SECONDS);
const cacheKey = new Request(`https://ratelimit.internal/${key}/${window}`);
// caches.default is per-location: cheap, approximate, resets with the window.
const cache = caches.default;
const hit = await cache.match(cacheKey);
const count = hit ? Number(await hit.text()) : 0;
if (count >= EDGE_LIMIT) {
const retryAfter = WINDOW_SECONDS - (Math.floor(Date.now() / 1000) % WINDOW_SECONDS);
return new Response(
JSON.stringify({ error: "rate_limited", scope: "edge", retry_after: retryAfter }),
{ status: 429, headers: {
"Content-Type": "application/json",
"Retry-After": String(Math.max(1, retryAfter)),
"CF-RateLimit-Tier": "edge",
} },
);
}
// Write-behind: do not make the client wait for the counter update.
ctx.waitUntil(cache.put(cacheKey, new Response(String(count + 1), {
headers: { "Cache-Control": `max-age=${WINDOW_SECONDS}` },
})));
return fetch(request);
},
};For the exact variant, a Durable Object serialises decisions for one key. Keep the object’s work tiny — read, decide, write — because every request for that key queues behind it.
// limiter-object.js — one instance per key, globally serialised.
export class RateLimiter {
constructor(state) { this.state = state; }
async fetch(request) {
const { limit, windowMs } = await request.json();
const now = Date.now();
const stored = (await this.state.storage.get("w")) ?? { start: now, count: 0 };
// Fixed window; swap for a token bucket if you need burst tolerance.
const w = now - stored.start >= windowMs ? { start: now, count: 0 } : stored;
const allowed = w.count < limit;
if (allowed) w.count += 1;
await this.state.storage.put("w", w);
const resetIn = Math.ceil((w.start + windowMs - now) / 1000);
return Response.json({ allowed, remaining: Math.max(0, limit - w.count), resetIn });
}
}
// In the Worker: only call this for keys that genuinely need exactness.
async function exactCheck(env, key) {
const id = env.RATE_LIMITER.idFromName(key); // same key => same instance, worldwide
const stub = env.RATE_LIMITER.get(id);
const res = await stub.fetch("https://do/check", {
method: "POST", body: JSON.stringify({ limit: 100, windowMs: 60_000 }),
});
return res.json();
}Gotchas and edge cases
CF-Connecting-IPis the only trustworthy client address.X-Forwarded-Forarrives client-controlled and can be spoofed to mint fresh buckets. Never key a limiter on a header a client can set freely.- Mobile carriers and corporate NATs share addresses. A per-IP limit that is tight enough to matter will hit a whole office at once. Prefer authenticated identity where you have it, and keep IP limits loose.
- Cache-based counters are not atomic. Two concurrent requests can both read the same count and both write
count + 1, so a busy key undercounts. That is acceptable for shielding and disqualifying for anything exact. - Durable Object placement is fixed at creation. The instance lives near whoever first created it, which may be nowhere near your users. For a global limit, accept the latency; for a regional one, create objects per region and shard the key.
waitUntildeferral means the next request may read a stale count. Under heavy concurrency the write-behind pattern trades a little accuracy for latency; make that trade knowingly.- Free-tier CPU limits are real. Keep the limiter’s work to a few milliseconds; a heavy JSON parse per request will hit the CPU budget before the rate limit does.
- Both tiers rejecting confuses clients. Make the edge limit a clear multiple of the origin’s, and mark the tier in the response so support can tell them apart.
Verification and testing
# 1. Confirm the edge rejects with a usable contract (not a bare 429).
for i in $(seq 1 700); do
curl -s -o /dev/null -H "X-API-Key: edge_test" https://api.example.com/v1/ping
done
curl -is -H "X-API-Key: edge_test" https://api.example.com/v1/ping | \
grep -iE "HTTP/|retry-after|cf-ratelimit-tier"
# HTTP/2 429
# retry-after: 23
# cf-ratelimit-tier: edge
# 2. Measure the multiplication: same key, two regions, compare accepted counts.
# Run from two hosts in different continents and sum the 200s.
# A per-location limiter will accept roughly 2x the configured number.The second test is the one that turns an abstract caveat into a number you can put in a design document. Run it once, record the result, and use it to decide whether the approximation is acceptable for the limit in question — the same measurement discipline the rate limit testing and validation guide applies to origin limiters.
Frequently Asked Questions
Is per-location counting good enough for a public API?
For a shield, yes: a limit set several times above legitimate use still stops floods, and the multiplication only helps clients who were not abusing you. For a number you publish and clients build against, no — use a coordinated counter or enforce the published limit at the origin.
Why not keep the counter in eventually consistent key-value storage?
Because its propagation delay is measured in seconds while rate limit windows are measured in seconds. A counter written in one location is not visible in another until after the window that needed it has passed, so the limit silently does nothing. Use it for configuration and allowlists, never for counts.
How much latency does a Durable Object add?
The round trip from the point of presence handling the request to wherever the object lives — a few milliseconds within a region, up to about 300 ms across the world. Because requests for one key serialise through one instance, a hot key also queues, so keep the object's work to a single read, decide, and write.
Should the Worker or the origin emit X-RateLimit headers?
The tier that owns the published limit should emit them — normally the origin. The Worker should reject only volumetric abuse and mark its rejections with a distinct header so an unexpected 429 can be traced to the right layer without guesswork.
Related
- Edge & Gateway Enforcement — the parent guide on tier selection.
- Layering Edge and Origin Rate Limits — keeping the two ceilings consistent.
- Fixed Window vs Sliding Window — the boundary behaviour of the window counter used above.
- Rate-Limit Response Headers — the contract an edge rejection must not break.