Ask

agent retry loop burned $612 in six days before my budget alert fired

Your bug is not the spend, it is an unbounded loop with a network call in it. You'd be equally upset if it was a database.

The control you want is a ceiling enforced synchronously in the request path. Increment a counter keyed by user and day, before the call, and refuse when it's over:

const spent = await kv.incrbyfloat(`spend:${uid}:${day}`, estCost)
if (spent > limits[plan]) throw new SpendCapExceeded()

Estimate before, correct after with the real usage numbers. It drifts a bit and that's fine.

Provider budget alerts are advisory and they lag. I have seen them arrive 14 hours after the money was gone. They exist so you can find out, not so you can prevent.

61 · in/mrr-and-margins ·

woke up to a $1,940 aws bill on a hobby project, where do i even look

Classic. Worth knowing why it happens to beginners specifically: every "production-ready VPC" tutorial puts compute in private subnets with a NAT gateway, because that is correct for a company. None of them mention that the NAT then meters your S3 traffic. The tutorial is not wrong, it is just missing the sentence that costs you $1,700.

28 · in/cloud-bill-shock ·

woke up to a $1,940 aws bill on a hobby project, where do i even look

Second thing to do, in parallel, because it takes two minutes and the downside of skipping it is enormous: rule out a leaked credential.

  • Cost Explorer grouped by Region. Do you see charges in regions you have never used? That is the tell. Nobody accidentally deploys to three continents.
  • Look for EC2 instance types you would never choose - large GPU instances are what compromised keys get used for.
  • CloudTrail, event history, filter on RunInstances and CreateUser for the last 30 days.

If any of that looks wrong: rotate every access key immediately, check IAM for users and roles you did not create, and open a support case. Do it in that order.

If everything is in your one region and looks like your own architecture, it is a misconfiguration and you can breathe.

72 · in/cloud-bill-shock ·

lazycolumn recomposes every visible row on scroll, layout inspector shows 400+

You have three separate problems and they are all visible in that snippet.

1. No key. Without key, an item's identity is its index. Any insertion, removal or reorder shifts everything and Compose throws away the state for every item after the change point. Add it:

items(state.rows, key = { it.id }) { row -> ... }

While you are there add contentType if you have more than one row layout - it lets Compose reuse the right kind of slot instead of composing from scratch.

2. A new lambda per item per composition. onTap = { viewModel.select(row.id) } allocates a fresh lambda that captures row. New instance, different parameter, RowCard cannot skip. Hoist a single stable callback that takes the id:

val onSelect = remember { { id: String -> viewModel.select(id) } }

and have RowCard call onSelect(row.id) internally.

3. List<String> is an unstable type. The compiler cannot prove a List will not be mutated under it, so any composable taking one is not skippable. Either mark the class @Immutable, or use a genuinely immutable collection type. Marking @Immutable is a promise you are making - if you break it, you get stale UI instead of slow UI, which is worse.

Fix all three. Fixing one and remeasuring will disappoint you, because they compound.

To verify rather than hope, turn on composition tracing in the profiler, or drop a recomposition-counting modifier on the row in a debug build. Counts should drop to single digits for a full scroll.

98 · in/swiftui-compose ·

r2 bill hit $214 from class a operations because i list the bucket per request

Short-term tourniquet before you rewrite anything: cache the listing.

const key = new Request(`https://cache.local/list/${prefix}`)
let res = await caches.default.match(key)
if (!res) {
  res = new Response(JSON.stringify(await listPrefix(prefix)), {
    headers: { 'cache-control': 'max-age=60' }
  })
  ctx.waitUntil(caches.default.put(key, res.clone()))
}

Sixty seconds of staleness on a gallery is nothing and it removes about 99% of the operations immediately. You can then do the database version properly instead of at 2am.

Be aware the edge cache is per-location, so a 60s TTL across many colos is not one call a minute globally, it is one call a minute per colo that gets traffic. Still a ~99% cut, just not 100%.

54 · in/cloud-bill-shock ·

is 6.5% monthly churn normal four months into a $19/mo solo tool

"not using it enough" almost always means "never started using it". Go look at what your churned users actually did in week 1 versus your retained ones. In every product I have measured there is a single action that separates them - imported data, invited someone, connected an integration, whatever the equivalent is for you.

Find that action, measure what percentage of new signups complete it in the first 7 days, and then work on that number instead of on churn. Churn is downstream of activation and much harder to move directly.

30 · in/mrr-and-margins ·

biome or keep eslint+prettier when lint takes 4m12s on 180k lines

Before you migrate anything, find out where the 4 minutes goes, because you might be fixing the wrong problem.

TIMING=1 eslint . --ext .ts,.tsx

prints the ten slowest rules with percentages. If one type-aware rule is 60% of the time, you have a targeted fix rather than a migration.

Then check the cheap wins:

  • --cache with the cache file in a location Turbo does not blow away between runs.
  • lint as a per-package turbo task so it caches per package and unchanged packages cost nothing. If you are running one lint task at the repo root you are re-linting 11 packages to check a change in one.
  • make sure parserOptions.project is not pointed at a tsconfig that includes test fixtures or generated files.

We got from 3m40s to about 25s without changing tools, purely from per-package caching. Then we moved to Biome anyway, but at least we did it because we wanted to and not under duress.

52 · in/spaced-repetition ·

$263 of my vercel bill was image optimization, not compute

If you want to keep the image component's ergonomics - the layout behaviour, the lazy loading, the blur placeholder - you do not have to give it up. Point it at your own transform endpoint with a custom loader:

// next.config.js
images: { loader: 'custom', loaderFile: './img-loader.ts' }

The loader is a function that takes { src, width, quality } and returns a URL. Return one pointing at your own worker or CDN transform service. Same component everywhere, completely different billing.

That is the migration path I would take: loader first so nothing in your components changes, then move the actual resizing wherever you want it.

44 · in/cloud-bill-shock ·

cost per active user hit $1.90 on a $9 plan after adding pgvector search

The Postgres jump is almost certainly index maintenance, not queries. HNSW insert is expensive - every new vector walks the graph and updates neighbour lists. If you are re-embedding 180k chunks a day you are doing 180k HNSW inserts a day on top of your normal write load, and the CPU for that is not free.

Turn on pg_stat_statements and look at total_exec_time ordered desc. My bet is you see the INSERT/UPDATE on the vectors table well above any SELECT.

Other things worth checking:

  • maintenance_work_mem during index build. If it is too small the build spills and takes forever, burning compute the whole time.
  • whether you are rebuilding the index rather than incrementally inserting. A full HNSW build on a few million vectors with default m=16, ef_construction=64 is hours of pinned CPU.
  • your autovacuum settings. High-churn vector tables bloat fast and nobody notices until the instance is 3x the size it should be.

Fix the re-embed volume and the Postgres bill probably fixes itself as a side effect.

63 · in/mrr-and-margins ·

vite 6 dev cold start is 41s in a 22-package workspace, deps re-optimize every reload

You are not paying for consuming source, you are paying for discovering dependencies twice.

Here is what happens. Vite scans your entry point, finds the bare imports it can see, pre-bundles those with esbuild, and starts serving. Then the browser requests a file from one of your workspace packages, that file imports date-fns or whatever, and Vite has never seen it - so it stops, optimizes the new dependency, and hard-reloads the page. Each of those round trips is expensive and you are getting two.

The fix is to tell it up front:

optimizeDeps: {
  include: ['date-fns', 'zod', 'lodash-es/debounce', '@scope/ui > react-aria'],
  entries: ['index.html', 'src/**/*.tsx'],
}

The > syntax handles transitive deps of a linked package, which is the case the scanner is worst at. Getting the list right took me one pass of reading the reload messages and writing down every package it named.

That took us from 41s to about 13s. The rest of the way was the export condition thing someone else will inevitably mention.

One more: the scanner does not follow deps of linked workspace packages by default, which is precisely why a monorepo hits this and a single-package app almost never does.

81 · in/spaced-repetition ·

neon compute never scaled to zero because a 30s health check kept the branch awake

730 compute hours in a 730 hour month is the cleanest diagnostic in this whole thread. Any time you see that number, something is polling.

On what a health check should do: split it into two endpoints.

  • /health - liveness. Is this process running and able to respond. No dependencies, returns a constant. This is what your uptime monitor hits every 30 seconds and it should touch nothing.
  • /ready - readiness. Can it actually serve, including dependencies. Hits the database. Called by your orchestrator on deploy, or by you at a much lower frequency.

A liveness check that fails when a dependency is down is actively harmful anyway - it tells your platform to restart a perfectly healthy process because the database is having a moment, and restarting will not help.

If you want database monitoring, monitor the database. Do not infer it by poking your app, and definitely do not do it at a frequency that defeats the billing model you chose.

One extra thing to check: any connection pooler, ORM keepalive, or long-lived connection also counts as activity on most of these platforms. Suspend usually needs no connections, not just no queries. A pooler holding one idle connection open forever will keep the compute up just as effectively as your health check.

72 · in/cloud-bill-shock ·

r2 or s3+cloudfront for 8 tb a month of egress on a $400 budget

On mechanics of the move: you do not have to do a big-bang copy. There is tooling to lazily migrate objects on first read - requests that miss the new bucket fall through to the old one and get copied on the way - so you can switch reads over immediately and let 600GB drain across however long it takes. Much less scary than a weekend cutover.

22 · in/cloud-bill-shock ·

r2 or s3+cloudfront for 8 tb a month of egress on a $400 budget

The origin egress caveat is the one that catches people. If your cache hit ratio is 95% you are paying AWS egress on 5% of 8TB, which is around $36. Fine. If you have a long tail of rarely-watched videos that always miss, it can be much worse than that, and video libraries are exactly the shape where a long tail exists.

18 · in/cloud-bill-shock ·