Skip to content

Stop paying Neon to stay awake for work nobody asked for - #115

Merged
Barneyjm merged 1 commit into
mainfrom
perf/neon-cost
Sep 14, 2026
Merged

Barneyjm merged 1 commit into
mainfrom
perf/neon-cost

Conversation

@Barneyjm

Copy link
Copy Markdown
Owner

Hit a $2 spend alert on Neon. Neon bills compute by the hour it is awake, and an idle compute
suspends — so the thing to minimize is not queries, it is wake-ups. Each one bills for the
whole autosuspend window whether or not there was anything to do.

Four changes, all aimed at keeping the compute asleep.

1. Lease-expiry cron: hourly → daily

crons = ["0 * * * *"]["17 4 * * *"].

Hourly was 24 wake-ups a day ≈ 2 compute-hours/day ≈ ~15 CU-h/month at 0.25 CU — plausibly most of
the bill — for work reclaimLapsedLocks() (src/operations.ts:418) had almost always already done
on the hot path.

The lazy sweep is the real mechanism: a stranded reservation is reclaimed by the next pool read, and
the only thing a pool read can be waiting on is a volunteer who wants work — exactly when it
matters. The cron is the floor under the case where nobody touches the system at all, where a
blocked budget also harms nobody until someone shows up.

2. /health no longer hits the DB by default

It ran SELECT 1. Any uptime monitor on a 1-minute interval would hold compute awake permanently
and outspend all real traffic combined. The probe moves behind ?db=1.

If you have a monitor pointed at /health, it keeps working unchanged — it just stops asserting
the DB. Point it at /health?db=1 only if you want readiness, and only on a slow interval.

3. Shared-cache headers on public reads

/leaderboard, /transparency, /tasks/available, /conjectures/:slug/tree,
/conjectures/:slug/contributions now send
public, max-age=60, s-maxage=300, stale-while-revalidate=600, so Cloudflare's edge answers site
traffic without touching Postgres. These pages are fetched on every visit to the marketing site.

Successes only — caching an error would pin a transient 500 at the edge for the full s-maxage.
Anything behind requireDev/requireAdmin stays uncached.

4. Runner poll interval: 15s → 60s

Four idle polls a minute held compute awake for an entire --watch session. A volunteer waiting up
to a minute longer costs nothing when the tasks themselves run for minutes. --interval still
overrides.

Tests

test/public-cache.test.ts holds the rule in both directions: public reads carry s-maxage;
caller-specific reads (/budget) and error responses never do. Full suite 48 files / 603 tests,
typecheck and Biome clean.

Not in this PR — console-side, needs your hand

  • Scale to zero: shortest allowed timeout. This multiplies against every wake-up above and is
    probably the single biggest remaining lever.
  • Min CU 0.25, and cap max CU low so a runaway query cannot autoscale into real money.
  • History/PITR retention: shortest allowed. Minor here (write-light), but free.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WoAJRWLbAW5pakUUzEzcjV

Neon bills compute by the hour it is awake, and an idle compute suspends after
its autosuspend window. That makes every avoidable query worse than it looks:
it is not the query that costs, it is the wake-up, which bills for the whole
window whether or not there was anything to do. Four changes, all aimed at
keeping the compute asleep:

- The lease-expiry cron drops from hourly to daily. Hourly was 24 wake-ups a day
  -- most of a near-idle month's compute -- for work reclaimLapsedLocks() had
  almost always already done on the hot path. The lazy sweep is the real
  mechanism: a stranded reservation is reclaimed by the next pool read, and the
  only thing a pool read waits on is a volunteer who wants work. The cron is the
  floor under a system nobody is touching, where a blocked budget harms nobody.

- /health no longer runs SELECT 1 by default. An uptime monitor on a 1-minute
  interval would hold the compute awake permanently and outspend all real
  traffic; the DB probe moves behind ?db=1 for when you actually want it.

- Public reads (/leaderboard, /transparency, /tasks/available, and the per-
  conjecture tree + contributions) carry a shared-cache Cache-Control, so
  Cloudflare answers ordinary site traffic without touching Postgres. Successes
  only -- caching an error would pin a transient 500 at the edge for its full
  s-maxage.

- The runner's poll interval defaults to 60s instead of 15s. Four idle polls a
  minute held the compute awake for a whole watch session, and a volunteer
  waiting up to a minute longer costs nothing when tasks run for minutes.

test/public-cache.test.ts holds the rule both ways: public reads are cacheable,
caller-specific reads and error responses never are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoAJRWLbAW5pakUUzEzcjV
@Barneyjm
Barneyjm merged commit 80dfe2b into main Sep 14, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant