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.

Per-location counting versus a single coordinating object Three points of presence each keeping a local counter enforce the limit three times over with no added latency, while routing every decision to one Durable Object gives an exact global count at the cost of a cross-region round trip. Two ways to count at the edge Per-location (approximate) FRA: 100 AMS: 100 SYD: 100 up to 300/min for a spread client added latency: none Durable Object (exact) one object instance holds the counter exactly 100/min globally added latency: up to ~300 ms cross-region approximate for shielding, exact for contracts — and never the reverse

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.

Lifetime of counter state in each Workers storage option An isolate variable survives seconds and is destroyed unpredictably, a location cache entry lives for the configured window in one region, key-value storage propagates over seconds globally, and a Durable Object holds state durably in one place. How long does the count survive? isolate variable seconds, unpredictable damping only location cache the window, one region good shielding default KV storage seconds to propagate never for counters Durable Object durable, one home exact, pays latency match the lifetime to the window: state that dies inside the window cannot enforce it

Step-by-step implementation

javascript
// 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.

javascript
// 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-IP is the only trustworthy client address. X-Forwarded-For arrives 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.
  • waitUntil deferral 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.
Choosing the storage primitive for an edge limiter If the limit is part of the published contract, use a Durable Object or the origin limiter; otherwise a per-location cache counter is enough, and eventually consistent key-value storage should never hold a counter. Which primitive holds the count? is this number published? yes: exact required Durable Object, platform binding, or the origin limiter no: shielding only per-location cache counter, generous ceiling, zero latency never: eventually consistent KV as a request counter

Verification and testing

bash
# 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.