In July we ran our first serious load test against Perron, the app we build that puts a chat room at every Dutch train station. We'd expected the chat rooms to be the expensive part. They weren't. The single biggest consumer of Durable Object requests was the thing that decides whether a request is allowed to happen at all.
Our rate limiter lived in a Durable Object. Every incoming API call asked it for permission first, and in the load test that came to 1,048 of 3,272 Durable Object requests. Almost a third of everything. We were paying, in the currency that mattered most on the free plan, to ask permission for requests we were going to serve anyway.
Cloudflare has a native answer to this: the rate limiting binding. It runs inside the Workers runtime, it isn't a Durable Object, and it doesn't show up on the Durable Object bill at all. So we moved all four of our limits to it in one afternoon, measured the result, and by the evening we'd moved three of them back. This is the story of that day, with the numbers, because the binding is genuinely useful and also genuinely not what its name suggests.
The limiter we started with
The original design was the one you'd sketch on a whiteboard. A Durable Object is single-threaded, so it's a natural place to count things exactly. A request comes in, the Worker works out a key (at first, the caller's IP address), sends it to the limiter, and the limiter checks a sliding window in SQLite: how many requests in the current minute, how many in the previous one, weighted by how far into the current minute we are. Under the limit, record the hit and say yes. Over it, say no.
We didn't create one object per IP. That would spawn a fresh Durable Object for every visitor who ever touched the API, and most of them would only ever hold a single row. Instead there are sixteen shards. The key is hashed to pick a shard, and each shard holds many keys in one table. Sixteen spreads the load comfortably, and it's held up fine.
It worked. It was exact. It returned a remaining count and a retry-after, so the app could back off politely. The problem was purely cost: one extra Durable Object request for every request the Worker served. When the free plan gives you 100,000 Durable Object requests a day for the whole account, a 32% tax on that is the difference between a comfortable app and one that falls over at lunchtime.
What the native binding is
You declare a limiter in your Wrangler config with a namespace, a limit and a period:
"ratelimits": [
{ "name": "RL_READ", "namespace_id": "1003", "simple": { "limit": 120, "period": 60 } }
]
and in the Worker you call it with whatever key you like:
const { success } = await env.RL_READ.limit({ key: deviceKey });
if (!success) return new Response('slow down', { status: 429 });
That's the whole API. It's quick, it doesn't leave the runtime, and it's free. Before you fall in love with it, read the limits section of the docs carefully, because three of its properties matter a lot more than they look:
The period can only be 10 or 60 seconds. There's no hourly window and no ten-minute window.
Counting happens per Cloudflare location, and Cloudflare describes it as approximate. Two requests that land in Amsterdam and Frankfurt are counted separately. Even within one location the counter is eventually consistent, not a lock.
The call returns { success } and nothing else. No remaining count, no retry-after header to pass on.
The docs are honest about this. They say outright that if you need an exact count, you should use a Durable Object. We read that sentence, nodded, and then moved everything anyway, because the cost numbers were so tempting.
The afternoon we moved everything
We had four limits at the time, all per minute: a small one for creating sessions, one for writes (posting on a station's wall, reporting something), a generous one for reads, and one for opening a WebSocket to a chat room. All four went to native bindings, keyed exactly as before.
Then we reran the load test with the same shape of traffic. The results looked like a win:
- The rate limiter's share of Durable Object requests fell from 32% to 2%. The 2% left was two email-related limits we hadn't moved, more on those below.
- The number of Durable Object requests per Worker request dropped from 1.9 to 1.5.
- Measured against the free plan's daily allowance, the same load went from 317% of the budget to 121%.
That last line is the one that got us excited. A test that would have blown through three days of quota now nearly fit in one. If we'd stopped there, this would be a short and happy blog post.
We didn't stop there, because a limiter that's cheap is only interesting if it also limits. So we wrote probes.
What the probes found
The first probe sent eight sequential requests to the session endpoint, which had a limit of five a minute. All eight went through.
A second probe fired twenty concurrent requests at the same endpoint, and the binding cut that burst to ten. Better, but still double the limit.
Then the read limit, set to 120 a minute. Two hundred concurrent reads: 191 allowed. One hundred and forty sequential reads, one after another from a single client: not throttled at all.
None of that contradicts the documentation. "Approximate, per location" means exactly this. The counter is replicated lazily, so a fast burst from one client can get most of the way through before the location's count catches up, and a slow steady stream may never trip it. For what Cloudflare designed it for, which is shaving off abusive volume cheaply, it does the job. As a promise that a user can post at most thirty times a minute, it doesn't hold.
The line we wrote into the code that evening was this one: "approximately thirty a minute, per datacentre" isn't a limit, it's a suggestion.
The walk-back: read stays native, the rest go home
So we split the limits by what they protect.
Reads went to the native binding and stayed there. A read limit exists to stop a runaway client or a scraper from hammering the API. If it lets through 191 instead of 120 during a burst, nothing bad happens; we serve a few dozen extra departure boards. And reads are about 60% of everything Perron serves, so keeping only this one limit native still keeps most of the saving.
Writes, sessions and sockets went back to the Durable Object. A write limit is a promise to the other people in a station's room that one person can't flood the wall. A session limit is there to make account farming tedious. Those need to be exact, or they're decoration. After the walk-back we reran the write probe: forty wall posts against a limit of thirty a minute gave exactly thirty allowed and ten refused. That's what a limit looks like.
Two more limits had never left the Durable Object, and never will. They guard the email sign-in flow: how many sign-in codes we'll send to an address in an hour, and how many attempts we'll accept to verify one in ten minutes. Both windows are longer than the native binding supports, so it was never an option. But even if it were, these are the limits that stop somebody from using our app to mail-bomb a stranger's inbox. They have to be exact, and they have to be global, not per location.
The middleware ended up with a small table that says which limits are native. There's exactly one entry:
// Only `read` uses the native limiter. That's a walk-back from moving all four:
// measured, the native limiter is much weaker than the Durable Object it replaced.
const NATIVE = {
read: (env: Env) => env.RL_READ,
} as const;
Everything else falls through to the sharded Durable Object. One honest confession: the Wrangler config still declares all four native limiters, with a long comment written on the optimistic afternoon. Three of them aren't referenced by any code. It's harmless, but if you're reading someone else's config to learn what they actually do, read the code too.
The bug the load test didn't find: station wifi
The same day brought a second fix that had nothing to do with cost and everything to do with what the app is for.
All of our limits were keyed by IP address, because that's the obvious key for an anonymous API. Now think about where Perron is used. People open it on a platform, often on the station's public wifi. Thirty travellers waiting for the same delayed train share one public IP. An open Perron screen polls departures every twenty seconds and status every minute, so it makes about four reads a minute. Thirty phones is 120 a minute. That's the entire read budget for the IP, spent by people doing nothing more than looking at the board.
And it failed silently. The departures poll quietly swallowed the 429, so the board didn't show an error. It just stopped updating, on exactly the day a train was delayed and everyone was staring at it. Perron is an app about the people standing on one platform. Throttling them collectively because they're standing on one platform is about the worst bug it could have.
The fix was to key by device instead of by IP whenever we can. Perron gives each install a device key and a signed session. Checking that signature is an HMAC in the Worker, no Durable Object call, so it costs almost nothing. When the session is valid, the limiter key becomes dev:<id> from the session. When there's no session, which happens for some anonymous endpoints, we fall back to the connecting IP.
function limiterKey(req: Request, session: Session | null): string {
if (session) return `dev:${session.deviceId}`; // one budget per phone
return `ip:${req.headers.get('CF-Connecting-IP') ?? 'unknown'}`;
}
This applies to both kinds of limiter. With the native binding it's just the key you pass to limit(). With the Durable Object it also decides which shard you land on, which spreads load better than IPs did, since a big shared IP no longer concentrates on one shard.
The swallowed 429 taught us something too. A stale board that says it's stale is honest. A stale board that looks fresh is a bug, whatever caused it.
The cost of a limiter that cleans up after itself
After the walk-back, the Durable Object limiter was back to doing real work for writes, sessions and sockets, and we went looking for other ways it cost money. We found one in its housekeeping.
Each shard swept out expired windows with an alarm. The original code armed that alarm unconditionally every five minutes, so every shard woke up 288 times a day whether or not it held a single row. Sixteen shards made that 4,608 Durable Object requests a day for sweeping empty tables. Our proof-of-device guard, also sharded sixteen ways, did the same thing. Between them it came to about 9,200 requests a day, roughly 9% of the free plan's Durable Object allowance, spent on nothing.
The fix is simple once you've seen it. A shard arms its cleanup alarm only when it writes a row, and when the sweep runs and finds the table empty, it doesn't re-arm. We also stretched the interval to ten minutes, since a window that's a few minutes past expiry costs nothing but a few bytes. Idle shards now cost zero.
If you build a sharded Durable Object of any kind, check its alarms with this question: does it wake up when it has nothing to do? We wrote more about this pattern in our post on Durable Object costs.
Other places we use the binding happily
The native binding isn't a failure. It's a different tool, and in some places it's exactly the right one.
Our WhiteBrick speaker app has a small diagnostics Worker that accepts optional log uploads from installs. It uses two native limiters: a global one at 500 a minute, and a per-install one at 12 a minute. Nobody's harmed if an install sneaks through 15 uploads in a burst. The goal is to stop a buggy client from flooding the bucket, and approximate is fine for that. It costs nothing and needs no storage.
For the Mahjong game's cloud saves, we went another way entirely. There we limit how often a save can be written by looking at the stored object's own upload timestamp in R2. The client can't edit that. A rate limit that a client can reset by editing a field it sends isn't a rate limit, so the rule is enforced against something the server recorded itself.
And for plain per-IP abuse protection on public routes, we lean on Cloudflare's WAF rate limiting rules rather than anything in a Worker. Those run before the Worker is invoked at all, which means an abusive client doesn't even cost you a Worker request.
How we'd choose today
If we were starting over, this is roughly the reasoning we'd follow.
When the limit protects you from volume, and going 50% over during a burst is harmless, use the native binding. Reads, uploads of optional data, expensive-but-idempotent endpoints. It's free and fast, and on the free plan it saves real quota.
When the limit is a promise to other users, or protects somebody outside your system, use something exact. A Durable Object with a sliding window in SQLite is easy to write and cheap if you shard it and keep its alarms idle-aware. Email sending belongs here without exception.
When the window is longer than a minute, the binding isn't available anyway, so the decision is made for you.
When the attack is per IP and you don't need any application context, put it in front of the Worker with a WAF rule.
And whichever you pick, choose the key carefully. An IP is a fine key for a server and a terrible key for people on public wifi. If your users have a session, use it.
What we'd tell ourselves in July
Measure the limiter, not just the bill. Our first test measured cost and celebrated, and it took a second, deliberately hostile test to show that the limits had stopped limiting. Those probes were cheap to write and they told us more than the whole load test did.
The binding cut our rate-limiting cost by more than ten times on the endpoint that carries most of our traffic. That's a big win, and we kept it. We just stopped asking it to do a job it never claimed to do.
If you're running something similar on the free plan, our notes on the Workers free plan limits cover the other cliffs, and the Wrangler environments post explains the staging setup we used to run these load tests without touching production.