Yes, and cache hits are prefix-based, so any change near the top of the prompt invalidates everything after it. People put a timestamp or a request id in the system message and then wonder why the hit rate is zero.
Kraut Corner
@kraut_corner
Makes cabbage in twenty-litre crocks and can tell kahm from mould at ten paces.
32 credit Contributor
- From answers
- 0
- From questions
- 33
- Lifetime
- 33
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.
Unrelated to your bug but you will hit it next: cache the pnpm store, not node_modules. Caching node_modules in a workspace repo restores symlinks that point at paths that may not exist and produces failures that are much harder to read than this one. pnpm store path gives you the directory to cache.
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.
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
RunInstancesandCreateUserfor 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.
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.
This is the one that survives all the other fixes. You can have perfect keys and stable parameters and still recompose the world because a parent reads a scroll offset. Worth learning the three phases - composition, layout, draw - just so you can ask which one a given read belongs in.
Agreed. The only additive-adjacent thing I'd say matters is going easy on bleach and antibacterial cleaners in large quantities, since dumping a lot at once isn't doing the tank any favours.
The silent-disable one got us for a month. We had rotated the token, CI kept building fine, and nobody noticed the pipeline had gone from 45s to 5 minutes because it crept up gradually. Now we assert on the summary output - if hits are below a threshold on a no-op commit, the job fails.
Divergence is also mostly one-directional in practice - orphaned objects in the bucket with no row, because a request died between PUT and INSERT. Those are harmless until they cost storage. Rows with no object are the scary case and you can make that nearly impossible by writing the row after the PUT succeeds.
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%.
"the bucket is the source of truth" is one of those sentences that is architecturally beautiful and financially expensive.
"I can print it and it is correct" is the most maddening class of bug there is.
"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.
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:
--cachewith 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.projectis 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.
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.
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_memduring 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=64is 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.
Same experience. Also worth saying the powered ones need a socket near the heater, which not every utility room has.
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.
Not a drop-in if you have any real Vite plugin chain - and in a 22-package workspace you do. Also "no pre-bundling" trades one cost for another rather than removing it. Worth evaluating on a greenfield app, not worth it as an answer to a 40 second cold start that has a 20 minute fix.
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.
How are you storing it and what board are you on? Two days of dinners on a glass or a stone board will kill any edge no matter how well you sharpened it, and knives rattling loose in a drawer lose their apex on the drawer, not on the food. Wood or a soft composite board and a magnetic strip fixed half my 'edge retention' problems.
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.
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.
Swarf wipes off. A straw or blue tint that stays after cleaning is heat. If you're checking often enough to ask, you're probably fine.