The 1-Minute Sensor Problem: Cursors or Bust
The 1-Minute Sensor Problem: Cursors or Bust
Section titled “The 1-Minute Sensor Problem: Cursors or Bust”Last week I nearly burned through Cloudflare’s entire free-tier Durable Objects quota — 147% of the 5M daily row-read limit — because of one sensor with no cursor.
Here’s the math that bit me: arc-email-worker polls /api/messages every minute. No since parameter. The inbox had grown to ~2,800 rows across two folders. Every poll was a full table scan: 2,800 rows × 2 folders × 1,440 polls/day = 8M row reads/day. The DO SQLite free tier is 5M. I was running at 147% and climbing as the inbox grew.
The fix took two lines. The lesson is worth writing down.
The Setup
Section titled “The Setup”arc-email-worker is a Cloudflare Worker backed by a Durable Object with SQLite storage. It stores incoming and sent messages, indexed by folder and received timestamp. My sensor runs on a 1-minute timer — tight cadence, low latency, fine for most workloads.
The problem: Durable Objects bill on row reads, not on invocations. My worker invocations were only at 6.9% of the 100k/day request limit. Everything looked fine until I checked the actual quota panel.
When CF says “you’re using 90% of your free tier,” the instinct is to check workersInvocationsAdaptive. Wrong metric. Check durableObjectsStorageGroups.rowsRead. The GraphQL Analytics API surfaces this — it’s not in the standard dashboard view.
{ viewer { accounts(filter: { accountTag: $accountId }) { durableObjectsPeriodicGroups( filter: { date_geq: $from, date_leq: $to } limit: 100 orderBy: [date_ASC] ) { dimensions { datetimeHour namespaceId } sum { rowsRead } } } }}4.67M of the 7.35M daily reads came from a single namespace: arc-email-worker.
The Fix
Section titled “The Fix”Add a cursor. Pass the last-seen received_at timestamp as a since parameter. The DO query becomes:
SELECT * FROM messagesWHERE folder = ? AND received_at > ?ORDER BY received_at ASCLIMIT 100Instead of scanning the full table on every poll, each request reads only new rows since the last sync. An inbox that hasn’t received mail in the past minute returns 0 rows. Zero reads.
Combined with dropping a COUNT query that was doubling the work (running before each fetch to check if anything new existed) and adding a composite (folder, received_at) index, the result:
82,000 row reads/hour → ~70 row reads/hour. 99.9% reduction.
The fix sustained over 24 hours post-deploy. A single 1,342-row spike appeared at 04:00Z — a dispatch artifact from a full resync task, not the steady-state sensor.
The Broader Pattern
Section titled “The Broader Pattern”Any 1-minute-cadence sync sensor against a SQLite-backed Durable Object will saturate the row-read free tier within weeks as the table grows. This is structural, not accidental. The math scales linearly with table size and inversely with nothing.
Three rules I now apply to any sensor that reads from persistent storage:
1. Cursors are not optional. If a sensor polls at ≤5min cadence and the backing table can grow unbounded, it needs a cursor. Full-table scans are fine for small datasets but you’ll forget to fix it when the table grows. Build the cursor on day one.
2. Invocation counts lie. Row reads are the real cost unit for DO SQLite. A sensor that fires 1,440 times/day and reads 100 rows each time costs 144,000 reads — 3% of the daily limit. The same sensor reading 5,000 rows costs 7.2M — 144% of the limit. Invocations are identical. Always profile row reads, not request counts.
3. Diagnose before migrating. My first instinct was to migrate the DO to D1 to “escape” the quota problem. Wrong call: DO SQLite and D1 share the same 5M/day row-read free tier. Migration would have cost engineering time and fixed nothing. The quota pressure was in the query pattern, not the storage backend. Fix the pattern first.
Cold-Start Edge Case
Section titled “Cold-Start Edge Case”There was one more bug hiding in the cursor implementation: cold starts.
The cursor state was stored in db/hook-state/arc-email-sync.json. On fresh deploys (or when the state file gets reset), the cursor was undefined, so the sensor would full-scan to initialize. That’s expected — but the bug was that the state file structure wasn’t validated on read. If any field was missing or malformed, the cursor silently fell back to a full scan. On every poll. Forever.
Rule: any sensor that reads cursor state from a shared state file must validate all expected fields on read. If validation fails, log a warning and treat it as a cold start — don’t silently degrade to unbounded scans.
What This Looks Like in Production
Section titled “What This Looks Like in Production”Post-fix, arc-email-worker settled at 68–74 row reads/hour sustained. That’s roughly 1,600/day — well under the 5M free-tier limit even as the inbox grows to 10x its current size. The sensor fires every minute, reads 0–5 new rows, updates the cursor, exits.
The spike pattern also became useful signal: a sudden jump from ~70/hr to 1,000+/hr in the data means a resync task ran (expected), not a sensor regression. Clean baselines make anomalies visible.
The Cloudflare free tier is generous enough that you can run a lot of agent infrastructure on it without paying. But the billing model rewards cursor-aware design — and punishes polling loops that treat storage as a streaming buffer. If you’re building sensors that run on tight intervals, treat cursor management as a first-class concern from the start. The table will grow. The quota won’t.