The 5-minute cron that billed me twice
Two background jobs, two vendors, two surprise bills. What each one taught me about hobby infrastructure that never sleeps.
Our household finance app has a small companion service: a control plane that keeps an audit trail and pushes configuration back to the app. The two talk through a pair of scheduled jobs. One ships audit events every 10 minutes; the other reconciles configuration 5 minutes later. From the outside that looks like a request every 5 minutes, forever.
That schedule cost me twice, through two different vendors, and I found out both times from a bill.
Bill one: an auth quota
The jobs authenticate with OAuth client credentials. In late September, Auth0's usage report showed token requests for that client going from about 13 a day to 89, then 258.
The cause. Each job fetched an access token and cached it in memory. On a warm serverless instance that cache held, which is why the baseline was low. But serverless instances don't promise to stay warm, and for two days they didn't. 258 a day is very close to the 288 you'd get if every single run fetched a new token. The token itself was good for 24 hours.
The fix was a second cache tier: store the token, encrypted, in a database row that survives cold starts, and share it between both jobs. Memory first, then the row, then Auth0. Only the last one costs anything.
The detour. Two coding agents worked on this, one in each repo, and between them they took a wrong turn I want to remember:
- The app-side agent looked at the source IP on the token requests, decided it wasn't a cloud address, and concluded the requests couldn't be coming from our jobs at all. It suggested the control plane was the culprit.
- The control-plane agent, given the same logs, concluded the opposite and described a fix that had already been merged.
Both were confident. What settled it was simpler: the fix had been merged but not released. Our production deploys from a release, not from main, so the scheduled jobs kept running the old code. The moment I promoted the release, the token requests every 5 minutes stopped. As the agent put it afterwards, the fix landing and the symptom stopping together is stronger evidence than reasoning about an address.
The follow-up that mattered most wasn't the cache. It was making the problem visible: every run now logs how many tokens it fetched. "1 every run" is the alarm. The cache deliberately fails soft, so if the encryption key or the database ever misbehaves, it quietly falls back to fetching a token per run. Without that log line, I'd find out the same way I did the first time.
Bill two: a database that never slept
Two weeks later, the Neon bill. Our databases scale to zero when idle, which is the whole point of using serverless Postgres for a hobby project. Except they weren't idle: a job every 5 minutes is shorter than the idle timeout, so both databases stayed awake around the clock. The first week of October cost about $10, on track for about $44 a month.
The fix was to slow both jobs to every 30 minutes, keep them offset by a couple of minutes so they share one wake-up, and move the control plane's heartbeat expectation to match. That last part was a cross-repo contract: if one side changed its schedule without the other, the control plane would have decided the app had gone silent.
The result, measured the day after the release:
| Awake before | Awake after | Compute per day | |
|---|---|---|---|
| Finance app database | ~99.6% | 36% | 6.1 → 2.2 CU-hours |
| Control-plane database | ~97.6% | 22% | 6.0 → 1.5 CU-hours |
Projected cost went from about $44 a month to about $16. The app's database was awake longer than the estimate because of one more hourly job, a push to my e-ink display, that wakes it at a different minute. Lining that up with the others would save another couple of dollars a month.
The trade-off is cold starts. When a database is asleep, the first request wakes it and the first page load is a little slower. For a household app, that's the right trade.
What I took away
- Cadence is a cost decision, not just a freshness decision. "Every 5 minutes" felt free. On serverless, it decides whether anything ever sleeps.
- Cache across cold starts, not across requests. An in-memory cache on serverless is a hope, not a guarantee.
- Merged is not shipped. If production deploys from releases, your fix isn't live until you release it, and scheduled jobs will keep running the old code without complaining.
- Make the expensive thing show up in your own logs before it shows up on a bill.
- Two confident agents can disagree. Give the evidence a chance to settle it before you pick a side. Mine didn't fully agree until the symptom stopped.