Durable Objects are priced in a way that's easy to read and surprisingly hard to reason about. You pay for requests, for the time objects spend awake, and, if you use SQLite storage, for rows read and rows written. Each of those is cheap. The trouble is that the code you write rarely tells you how many of each it's about to spend.
Perron, our train-station chat app, ran on the Workers free plan for its first months, where the daily allowances are a cliff rather than a slope. On the free plan you get 100,000 Durable Object requests a day, 5 million SQLite rows read and 100,000 rows written, across the whole account. Go over and the app stops answering until midnight UTC. That made us look at our usage far more carefully than we would have otherwise.
This post collects the fixes that made the biggest difference, in roughly the order we found them. Most are embarrassing in hindsight. All of them were invisible until we measured. Perron is on Workers Paid now, since 2026-10-15, so these are no longer outages for us, but on Paid they're a bill, and the lessons are the same.
The app stopped working at 500 users
In July we found that Perron stopped working at about 500 daily users, and nothing told us. No error, no alert. It just ran out of Durable Object requests for the day.
The arithmetic was brutal once we did it. One station screen, left open, made 16 Worker requests and about 20 Durable Object requests a minute. The board polled departures. A separate timer polled disruptions. Another polled the room list, another the headcount, another the station wall, and so on, six polls on six timers. Each was reasonable on its own. Together, against an allowance of 100,000 Durable Object requests a day, they meant the whole app could afford about five thousand station-screen-minutes per day. That's 500 people looking at the board for ten minutes.
The fix was dull and effective. Six polls became one composite endpoint on one timer. And the screen stops polling when it isn't on screen, which sounds obvious and which we hadn't done. Requests per minute for an open station screen went from 16 to 3, and Durable Object calls from about 20 to about 2. The ceiling moved from roughly 500 daily users to somewhere north of 3,000.
The lesson we took from this one is to do the per-user arithmetic early. Take what one active screen costs per minute and multiply it out against the daily allowance. If the answer is a number of users you'd be embarrassed to be limited by, fix it now.
A third of our requests were the rate limiter
Around the same time, a load test showed that 32% of all our Durable Object requests went to the rate limiter. Every API call asked a Durable Object for permission first. Moving the high-volume read limit to Cloudflare's native rate limiting binding, which runs in the Worker runtime and costs no Durable Object request at all, cut that to 2%.
The full story, including why we moved three of four limits back, is in our rate limiting post. The general point: count how many Durable Object calls each Worker request makes. For us it was 1.9 before and 1.5 after. Anything that adds a hop to every request deserves a hard look.
Alarms that never sleep
Our rate limiter and our device-proof guard were each sharded across sixteen Durable Objects. Each shard cleaned up expired rows with an alarm every five minutes. Unconditionally. Whether it had any rows or not.
Sixteen shards waking 288 times a day is 4,608 requests. Two sharded classes doing the same thing came to about 9,200 Durable Object requests a day, roughly 9% of the free allowance, to sweep tables that were usually empty.
The fix is a pattern we now apply to every alarm: arm it when you write something that will need cleaning up, and when the sweep finds nothing left, don't re-arm. Idle objects should cost nothing. We also stretched the interval to ten minutes, since a row that lingers a few extra minutes costs a few bytes.
Ask this of every setAlarm() in your code: does this object wake up when it has nothing to do?
4.55 million rows read, from 276 calls
In August, Cloudflare's own email about the free plan arrived before anything inside our app had noticed a problem. We'd been watching request counts closely, but not SQLite rows read.
Cloudflare's GraphQL analytics told the story quickly: a single Durable Object, our global Stats object, accounted for 4.55 million of the day's 4.58 million rows read. From 276 invocations. That's about 16,500 rows read per call.
The culprit was Stats.summary(), which fed the admin dashboard. It produced a per-day series grouped by day, metric and station. With up to 7,512 stations in the data, that was a lot of rows. And every caller immediately summed the station dimension away in JavaScript. We were reading up to 7,512 times more rows than anything used, every time someone opened the admin page.
Our first fix grouped by day and metric in SQL, so the station dimension never crossed back to JavaScript. It made the result smaller. It did nothing for the bill.
GROUP BY reduces rows returned, not rows read
This is the line from that week we'd most like other people to see before they learn it the hard way.
SELECT metric, SUM(count) FROM counters WHERE day >= ? GROUP BY metric still reads every row in range before it can sum them. SQLite has to look at each (day, metric, station) row to add it up. Grouping changes what comes out of the query. It doesn't change what goes in, and rows read is billed on what goes in.
After the first fix, rows read for the Stats object was still climbing at a steady 200,000 an hour. What actually fixed it was a rollup table. daily_metric_totals holds one row per day and metric, and the same code path that increments a station's counter also increments the total. Reads for the dashboard now touch at most days times metrics rows, regardless of how many stations exist or how wide a date range someone asks for.
-- kept in step by incr()/incrMany(), next to the per-station row
CREATE TABLE IF NOT EXISTS daily_metric_totals (
day TEXT NOT NULL,
metric TEXT NOT NULL,
n INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day, metric)
);
Writing two rows instead of one costs a little more on the write side. Reads are where the volume was, so it's not close.
While we were in there, we deleted a top-stations query from the same function whose output no caller ever read. It's remarkable how often the most expensive query in a system is one nobody uses.
The migration that ran every five minutes, forever
Even with the rollup in place, rows read kept climbing at that same flat 200,000 an hour. It didn't track admin traffic at all. wrangler tail showed no HTTP traffic to the Worker for minutes at a time, because the app really was idle, and yet the Stats object logged hundreds of invocations at a steady rhythm that matched the five-minute cron.
The cause was a data migration. Earlier we'd re-coded some station identifiers, and a function called migrateRecodedStations() fixed old rows. It was written to be idempotent and to run once. It was called from the object's constructor.
On a busy app, a Durable Object stays alive and its constructor runs rarely. On a quiet app, the object hibernates between cron ticks and is reconstructed every time the cron wakes it, roughly every five minutes. And the migration's WHERE station = ? queries couldn't use the primary key, since station was its last column, so each run full-scanned the counters table. "Run once" was running about 288 times a day.
The fix gates the migration behind a marker: an in-memory flag first, then a persisted row in a migrations_done table. Now it does real work exactly once per fresh database and costs one indexed lookup after that, including across hibernation.
Two lessons. A constructor isn't "once". On Durable Objects it's "every time the object wakes up", and on a quiet app that's often. And "idempotent" only means the result is the same each time. It says nothing about the cost.
Persisting nothing on purpose
One of our objects, FeedCache, holds the national train-updates feed. It's a 1.5 MB protobuf, re-published about once a minute, parsed into departures for 397 stations.
Our first instinct was to store the parsed departures in the object's SQLite so a restart wouldn't lose them. We did the sums first. Writing every station's departures on every refresh would cost six figures of rows written a day, against a free allowance of 100,000. So FeedCache persists nothing. It keeps the parsed feed in memory, and if it's evicted, it fetches and parses again.
That only works if fetching and parsing is cheap, so we made it cheap. The feed's publisher, a volunteer-run open-data service, sends an ETag and honours If-None-Match, and we simply hadn't been asking. Now an unchanged feed answers 304 with no body, and we skip the 110 ms parse entirely. The ETag, not the status code, gates the parse, because Cloudflare's cache can answer a conditional request with a cached body instead of a 304. We also send a User-Agent with a contact address now, which is basic manners for a free service.
We learned the politeness lesson twice. The publisher throttles hard per IP, and Workers share outgoing IPs with other people's Workers, so we sometimes got throttled through no fault of our own. On a 429 we now stand off that source for ten minutes and use a fallback feed, and a last-good copy in R2 means a long outage serves a stale board instead of an error.
Cold starts cost latency, not just money
An object that persists nothing and hibernates when idle has a cold-start problem. After two quiet minutes, the departures board took 1.1 to 1.2 seconds instead of about 20 ms, because the evicted FeedCache had to download the feed again before it could answer.
The obvious fix is a keep-warm alarm. We considered it and said no: on the free plan, a resident object would have eaten most of the daily Durable Object duration allowance. Instead, every refresh also writes the last good feed to the edge cache, stamped with when it was stored. A cold object parses that recent copy, which is local and fast, answers from it, and refreshes behind it. The copy is only used when it's under ten minutes old.
The same trick helped elsewhere. Cold isolates were spending 220 to 430 ms on their first runtime config read, a round trip to the ConfigStore object. The config is now also kept in the edge cache, stamped with its age, so a cold isolate adopts it immediately. An admin save deletes the cached copy, so a change still lands everywhere within the usual 60 seconds and never looks ignored.
The Cache API isn't metered by Cloudflare, which makes it a good place to put things a Durable Object would otherwise have to answer again and again.
Buffering counters
Every chat room counts things: messages, visitors. Sending each count to the global Stats object as it happens would be a subrequest per event and a hot spot for the whole country. So rooms buffer counts in a little SQLite table and flush them in one call every sixty seconds, from an alarm.
We wrote about it in more detail in the chat rooms post. The comment in the code puts it well: a counter that costs more than the thing it counts is a bill.
Watching the right numbers
The reason most of these went unnoticed is that we watched the wrong numbers. We tracked Worker requests and Durable Object requests closely, because those were the limits we'd read about first. SQLite rows read and written weren't on our dashboard at all until Cloudflare's own email told us about them.
Perron now has a small quota alarm on its five-minute cron. It reads the account's usage from Cloudflare's GraphQL Analytics API, including Durable Object requests and SQLite rows read and written, and sends an email at 50%, 75% and 90% of the daily allowance, once per threshold per day. The state lives in a KV key per UTC day.
For investigating, two tools did most of the work. GraphQL analytics broken down by Durable Object namespace shows which class is spending, and that's usually enough to find the culprit within a minute. wrangler tail shows what's actually calling it, which is how we found the cron-driven migration. Server-Timing headers on the slow endpoints told us where cold-start time went.
A short list to check your own app against
Multiply one active screen's per-minute cost out against the daily allowance. Count Durable Object calls per Worker request. Check that every alarm stops when there's nothing to do. Look for queries that read wide and return narrow, and add a rollup instead of a GROUP BY. Never do expensive work in a constructor without a persisted "done" marker. Decide deliberately what not to persist. Use the edge cache for things a cold object would otherwise fetch. And put rows read and rows written on your dashboard before Cloudflare emails you about them.
None of these were clever. Each one was a few lines once we knew where to look. Knowing where to look was the whole job.
For the free-plan numbers these fixes were designed around, see Cloudflare Workers free plan limits. For when a Durable Object is the right store at all, see D1, KV, Durable Objects or R2.