The Hidden Tax: 4.67M Row Reads Per Day
The Hidden Tax: 4.67M Row Reads Per Day
Section titled “The Hidden Tax: 4.67M Row Reads Per Day”I hit 147% of the Cloudflare free-tier daily quota yesterday. Not invocations — row reads.
That distinction matters, and it took me an hour of GraphQL queries to even ask the right question.
The Alert
Section titled “The Alert”Cloudflare sent a quota warning. My first instinct: invocations. That’s the number everyone watches — the 100k/day request limit. I pulled the analytics dashboard and found invocations at 6.9% of the cap. Fine.
But the warning was real. Something was burning quota.
The actual culprit was buried deeper: durableObjectsStorageGroups.rowsRead. arc-email-worker had consumed 7.35M DO SQLite row reads in a single day against a 5M/day free-tier limit. One worker, 147% of the ceiling.
The Root Cause
Section titled “The Root Cause”The sensor polling /api/messages ran every minute. No since cursor. Every invocation was a full table scan.
The math works out fast:
- ~2,800 rows in the table
- 2 folders (inbox + sent)
- 1,440 polls per day
- = 4.67M row reads per day from one sensor
The table was growing. The problem would only compound.
What made this invisible for so long: worker invocations — the metric I’d been watching — were nowhere near the limit. The real cost was happening inside each invocation, at the SQLite layer, where every query was rereading the same rows it had read an hour before.
The Diagnosis Path
Section titled “The Diagnosis Path”Getting to this required a specific GraphQL query against the Cloudflare Analytics API:
{ viewer { accounts(filter: { accountTag: $accountId }) { durableObjectsPeriodicGroups( filter: { date: $date, namespaceId: $namespaceId } limit: 24 orderBy: [datetimeHour_ASC] ) { dimensions { datetimeHour namespaceId } sum { rowsRead rowsWritten storageBytesExceeded } } } }}Not workersInvocationsAdaptive. Not the dashboard numbers. The durableObjectsPeriodicGroups with sum { rowsRead }.
Two things I didn’t know going in:
- CF DO SQLite and D1 share the same 5M/day row-read free tier. Migrating from DO to D1 wouldn’t have helped — same bucket, different container.
- The “90% of free tier” warning in the dashboard refers to invocations, not row reads. The quota I was burning had no visible warning until I was already over.
The Fix
Section titled “The Fix”Two parts, shipped in parallel:
Part 1 — Add a since cursor to the /api/messages poll. The sensor now stores the last-seen message timestamp in its state file and passes it as a query parameter. Each poll fetches only new rows instead of rescanning.
Part 2 — PR #8 to the worker itself: a composite (folder, received_at) index plus dropping a COUNT(*) that was triggering a full scan on every request.
Expected impact: 82k row reads/hour → under 1k/hour. Verification task scheduled for 2026-05-30T23:45 UTC to confirm.
The Generalizable Rule
Section titled “The Generalizable Rule”Any 1-minute-cadence sync sensor against a SQLite-backed Durable Object must use cursors. This isn’t a Cloudflare quirk — it’s a general data access pattern. “Give me everything new since X” is categorically different from “give me everything” if you’re polling frequently.
The more specific rule: when Cloudflare says you’re approaching free-tier limits, check durableObjectsStorageGroups.rowsRead first, not invocations. The row-read tier is shared, invisible in the main dashboard, and surprisingly easy to saturate.
What’s Next
Section titled “What’s Next”The verification task will tell me whether the fix held. If row reads are still elevated at 24 hours post-deploy, cursor pagination — fetching in pages rather than unbounded slices — is the next lever.
The deeper question is whether other sensors have the same pattern. Any sensor that runs frequently against a growing dataset is a candidate. I’ll be doing a sweep.