Search nomadLab

Cloudflare Free Plan Rate Limiting Cannot Guard 1,000 KV Writes a Day

The Workers free plan allows 1,000 KV writes a day, shared by everyone. The rate limiting binding let 82 percent of a burst through, WAF rate limiting rules on a free zone are stuck at a 10 second window, and neither can express a daily cap. A per address Durable Object can.

Snip is a URL shortener I put on snipp.cc this month. It runs on the Cloudflare Workers free plan, and most of the design is a negotiation with a single number. The free plan allows 1,000 KV writes per day. Creating a link is exactly one write. So a thousand new links a day is the ceiling for the whole service, and it is one pool that everybody shares. The last limit on this plan that caught me out was 10 ms of CPU per invocation. This one is quieter.

That ceiling has a sharp edge. Going over does not produce a bill, because the free plan does not upgrade itself. It produces a failure: the write is rejected and the person in front of the box gets nothing. The limits reset at 00:00 UTC, so the worst case is that link creation is dead until midnight while every link that already exists keeps redirecting, since redirects are reads.

Cloudflare limits and doc tables quoted below were read on 24 August 2026.

Which means the interesting question is not how to survive a flood. It is how to stop one address from quietly spending everyone else’s day.

The limiter I shipped let 82 percent through

I used the Workers rate limiting binding, keyed on cf-connecting-ip, at five creations per sixty seconds. The docs mention neither permission nor prohibition for that tier on the free plan, so I found out by deploying.

Then I tested it wrong, which is the part worth keeping. My original check was to send six requests inside a minute and watch the sixth come back 429. All six came back 200. The check I had written down would have reported a working limiter as a dead one, and I nearly went looking for the bug in my own code.

The docs explain it in a sentence I had already read: the API “is permissive, eventually consistent, and intentionally designed to not be used as an accurate accounting system.” Each isolate checks a locally cached counter. Very quickly, but not immediately, those requests find each other.

So I tested it the way the property demands. Empty the window, then send forty requests from one address and see whether 429s appear at all.

requests sent:   40
first 429 at:    #27
total blocked:   7

Thirty-three got through. I had been thinking of the counter as per data center, which would have given a determined caller five multiplied by the number of locations. The real behavior is closer to per machine. At that pass rate, roughly 1,200 requests from a single address empties the day’s 1,000 writes. That is under three minutes of work for anybody, and nothing about it looks like an attack.

The window is the problem, not the number

My first instinct was to tighten it. That instinct is wrong, and the arithmetic is short enough to do in your head.

The binding accepts a period of either 10 or 60 seconds, and nothing else. Cloudflare’s zone level rate limiting rules are stricter still on a free zone: the counting period is fixed at 10 seconds. So the tightest rule I can write anywhere is one request per ten seconds per address.

One per ten seconds is 8,640 per day. The budget is 1,000. The tightest expressible rate is more than eight times too loose, and it takes about two hours and forty minutes to spend the day at that pace.

Work it the other way. To make a rate imply the daily cap, I would need one link per 86 seconds. That is a minute and a half of waiting between links, which kills ordinary use to protect a budget that ordinary use was never going to touch.

It took me two days to see it. A rate limit and a daily budget are different units. Squeezing the window never produces the second one, and squeezing it far enough to try makes the tool unusable. I had been treating my limiter as a weak version of the thing I wanted. It was not a weak version. It was a different thing.

I had thrown out the right tool for a wrong reason

While I was rereading my own notes I found a decision I had recorded and never checked again.

I had evaluated Cloudflare’s zone level rate limiting rules early and rejected them. The note said: a free zone cannot match on Method, so I cannot target only POST. That is true. The availability table gives a free zone one rule, counting by IP, a fixed 10 second window, a fixed 10 second block, and exactly two fields in the expression, Path and Verified Bot.

But Snip creates links at POST /api/links, and that path does nothing else. Anything other than POST there returns 405. Path is one of the two fields a free zone gets. The rule I said I could not write is a single line:

http.request.uri.path eq "/api/links"

I read the constraint correctly, drew a conclusion, wrote the conclusion down, and never went back to check it against the shape of my own service. The doc had not changed. I had just stopped looking.

Durable Objects became free in April 2025

The other thing I had not checked was newer than my mental model. Durable Objects have been on the Workers free plan since 7 April 2025, SQLite backend only, with 100,000 requests and 13,000 GB-seconds of duration per day and 100,000 rows written per day.

That matters because of why I had ruled out counting at all. A per address counter in KV needs a write per creation, so it would spend the budget it exists to measure. In a Durable Object that objection disappears, and since creation is already capped at 1,000 a day by KV, the object’s own usage rounds to nothing.

It is also the only option on the table that can express a day. Cloudflare’s own rules of thumb call a global rate limiter an anti-pattern, and they are right, but the anti-pattern is funneling all traffic through one instance named global. One object per address is the sharding they recommend. The whole class is six lines:

export class IpQuota extends DurableObject {
  async take(day: string, limit: number): Promise<boolean> {
    const next = nextCount(await this.ctx.storage.get<Counter>('c'), day, limit);
    if (!next) return false;
    await this.ctx.storage.put('c', next);
    return true;
  }
}

No lock and no blockConcurrencyWhile, because a Durable Object’s input gate holds the next event while a storage operation is in flight. The day is passed in as a UTC date string, since the KV budget resets at 00:00 UTC and a counter on a different day boundary would be guarding something other than the thing it is named after. There is no alarm to expire the counter either. The next request on a new date resets it for free, and setAlarm() would bill a row write to accomplish the same nothing.

Two layers, two budgets

The rework ended with more enforcement and less code. The binding came out entirely.

LayerRuleBudget it protectsRuns
Zone rate limiting ruleone creation per 10 seconds per addressrequest count, 100,000 per daybefore the Worker
IpQuota Durable Object50 creations per day per addressKV writes, 1,000 per dayinside the Worker, just before the write

The zone rule sits in the http_ratelimit phase, which runs ahead of Workers, so a blocked burst never costs a Worker invocation. Its block response is configured to return the same 429 and the same JSON body my Worker returns, so the browser code cannot tell the two apart.

The quota is counted immediately before the KV write rather than at the front of the request. Put it earlier and a typo in a URL costs someone a slot, and the thing being counted quietly becomes requests instead of writes.

The five per sixty seconds binding had nothing left to say between a ten second rule and a daily cap, so I deleted it.

What the two layers actually measure

Locally, requests 1 through 50 returned 201 and the 51st returned 429. Exactly the 51st. A Durable Object is strongly consistent, so that number is not an approximation.

In production the zone rule behaves like its documentation. Three requests in a row to /api/links:

#1 -> 400 blocked_destination
#2 -> 400 blocked_destination
#3 -> 429 rate_limited

Two got through a limit of one per ten seconds. Counters are per data center and eventually consistent, and the docs say plainly that excess requests can arrive before enforcement starts. One trial of three is not a measurement of the steady state, but it is enough to know the shape. The edge layer is approximate. The exact counting happens in the object.

That distinction went onto the page itself, because the version I wrote first said “one every ten seconds” and that is a precision I cannot keep. Now the site says about one every ten seconds, explains that an extra link slips through sometimes, and states the daily 50 as a firm number. A published limit you miss by 100 percent is a bug report waiting to be filed by a stranger.

I also picked 50 for a reason that has nothing to do with defense. Cloudflare’s own guidance recommends against keying on IP precisely because addresses get shared, and carrier NAT puts a lot of unrelated people behind one of them. A tighter cap protects the budget better and starts hitting real users sooner. Right now the service creates a handful of links a day, so blocking a real person costs me more than a burned budget would.

Neither layer stops the case I cannot solve: a caller rotating through addresses. Both are keyed on the address, so both are blind to it. The honest answer there is Turnstile, which would close the browser path and break the documented scripted one, and I am not paying that price for an abuse pattern that has not shown up yet.

Nothing has attacked this yet, and at the traffic it sees now, nothing will for a while. What made it worth the afternoon is the shape of the failure it prevents. No error page, no alert. Just a box on a page that stops making links until midnight UTC, for everybody, because one person was in a hurry.

Keep reading