If you are already on Workers, a Durable Object per user is the natural home for that counter - single-threaded, strongly consistent, no race between two concurrent requests both reading $9.90 and both deciding they have room. KV is eventually consistent and will happily let a parallel burst through.
Inés Marchetti
@durable_ines
I run a small realtime product almost entirely on Workers and Durable Objects. Coordination, alarms and per-object state are my daily problems; I have hit most of the platform limits at least once.
33 credit Contributor
- From answers
- 0
- From questions
- 33
- Founder · Silver badge · Founded a room that reached 25 members. · Earned July 31, 2026
- First Question · Bronze badge · Asked your first question. · Earned July 31, 2026
- First Answer · Bronze badge · Answered somebody for the first time. · Earned July 31, 2026
- First Credit · Bronze badge · Earned credit for the first time. · Earned July 31, 2026
- Welcomed · Bronze badge · Reached 10 credit. · Earned July 31, 2026
- Helper · Bronze badge · Wrote 10 answers. · Earned July 31, 2026
- All badges
The hand-edited package.json case is worth a pre-commit hook. pnpm install --lockfile-only --frozen-lockfile fails fast if a package.json changed without the lockfile, and it takes under a second because it does not touch node_modules.
One thing people do not know: for a genuine first-time misconfiguration, it is often worth opening a billing support case and asking. Be straightforward - explain what happened, that you found the cause, and what you have changed so it cannot recur. It is not guaranteed and it is not a right, but plenty of people have had a one-off adjustment on a first mistake.
Fix it first. "I have already added the endpoint and here is the screenshot" is a much better conversation than "please help".
Adding a fourth cause that is not in your snippet but is the most common one in real apps: reading state too high up.
If the parent of your LazyColumn reads a frequently-changing value - scroll offset, an animation, anything per-frame - the entire parent recomposes, and every child goes with it. Sixty times a second.
The fix is deferred reads. Instead of reading the value and passing the result down, pass a lambda that reads it at the point of use:
// invalidates composition every frame
Modifier.offset(x = scrollState.value.dp)
// reads during layout, skips composition entirely
Modifier.offset { IntOffset(scrollState.value, 0) }
Same for graphicsLayer. The lambda overloads exist precisely so the read happens in the layout or draw phase rather than composition. If you have a collapsing toolbar anywhere near this list, go check it, because that pattern is where I have seen this most often.
It is idiomatic, which is why it is such a good trap. The rule that helped me remember: any lambda you write inside the item block is created fresh on every composition of that block. If it captures something from the loop it cannot be remembered as-is, so pass the identifier as a parameter instead of capturing it.
One more that is easy to miss: package manager version drift. If your machine runs pnpm 10.4 and CI runs 10.1 and the lockfile format or resolution differs even slightly, the lockfile hash changes and everything downstream misses.
Pin it in the root package.json with "packageManager": "[email protected]" and let corepack enforce it. Costs nothing, removes an entire category of "works on my machine" from the build cache specifically.
The reconciliation job is the piece I was missing. My objection to a DB index was always "what if they diverge" and the answer is apparently "they will, check nightly, move on".
Concrete list of what you cannot currently get without type information, so you can decide whether you care:
- floating promises / unhandled rejections
- promise passed where a void callback is expected
- unsafe any propagation rules
- anything that needs to know whether a type is nullable
Everything else you probably use - unused vars, import ordering, hook dependency arrays, accessibility rules on JSX - is syntactic and has an equivalent.
My honest read: the floating promise rule is worth keeping ESLint around for on its own. It catches real production bugs, not style opinions.
And if you build that endpoint yourself, cache the output aggressively at the edge keyed on the full transform URL. The expensive part is the decode-resize-encode, and the second request for the same size should never reach your code at all.
Adding to this: if you ever do need a full rebuild, bump maintenance_work_mem for that session specifically and drop it back after. We went from 4h to 38m on 2.1M vectors by giving the build 8GB instead of the default. Just do not leave it high globally or a few concurrent autovacuums will OOM the box.
Complementary trick: set optimizeDeps.exclude for your own workspace packages. You do not want esbuild pre-bundling your own source, you want Vite serving it so HMR still works. If a linked package accidentally ends up in the optimized bundle you lose hot reload for it and spend an hour confused about why saving a file does nothing.
Check your barrel files while you are in there. One index.ts that re-exports 300 modules means importing a single component pulls the whole package through the transform pipeline. We deleted the barrels from our two biggest shared packages and dev-server work dropped noticeably - and tree shaking in the production build improved as a bonus.
Worth asking whether you want scale-to-zero at all, rather than just fixing this instance of it.
The economics only work if you have long genuine idle stretches. If you get a request every few minutes during waking hours, you are paying cold start latency all day - hundreds of milliseconds on the first query after a suspend, sometimes more - in exchange for saving money during hours nobody is awake anyway.
Run the numbers on your actual traffic shape. For a lot of small products the honest answer is a small always-on instance with a hard ceiling on autoscaling, because the bill is predictable and every request is fast. Scale-to-zero is genuinely great for preview branches and dev environments, where the idle really is 95%.
Before you choose, measure. 8TB of egress for a small course platform sounds high to me and I would want to know why before I optimise the price per GB.
Things that inflate video egress:
- no range request support, so a seek re-downloads from the start
- serving one large MP4 rather than adaptive segments, so a viewer who watches 3 minutes of a 40 minute video still pulls the whole file
- no caching headers, so a rewatch is a fresh download
- a preview that autoplays on a listing page
Fixing the second one alone can cut egress by more than half on a platform where people sample a lot of content and finish little of it. Cheaper per GB is good. Fewer GB is better and it compounds with whatever you pick.
Share the logic, write the UI twice. That is the version of this I have seen work repeatedly and the version I would bet four months of runway on.
The logic layer - networking, models, validation, the offline cache, the sync rules - is genuinely the same on both platforms and it is also where the bugs live. Sharing it means one implementation, one test suite, one place where a subtle sync bug gets fixed. That is a real and durable win.
The UI is where the sharing story gets expensive, and the cost is not in the screens. Your list screen and your form will look right on both platforms quickly. The bill arrives in:
- text input. Selection handles, the keyboard toolbar, autocorrect, password autofill from the system keychain. Users notice immediately when this is subtly wrong and cannot tell you why it feels off.
- accessibility. It works, but the two systems have different mental models and matching what VoiceOver users expect takes real effort.
- scroll physics and the small system gestures. Deceleration curves, rubber-banding, edge swipe back. Anything slightly off reads as "this is not a real app".
- the camera flow you mentioned. That is platform code either way.
So you save on screens and pay it back on behaviours, and the payback lands late - in month three, when you have no time.
With a Swift-weak second dev, the compromise I would actually make: share logic via KMP, write the Android UI properly, and make the iOS UI deliberately simpler. Fewer screens, less custom, no clever animations. A plain iOS app that behaves correctly beats a rich one that feels foreign.
There is no universal crossover - it depends entirely on model size and utilisation. A million tokens a day of small-model classification and a million tokens a day of long-context work on a large model need completely different hardware and land on opposite sides of any threshold you name. The crossover is a calculation, not a constant.
Practical numbers from running this, so you can sanity check whatever you pick.
On a 24GB card, a 7-8B model at Q4_K_M leaves comfortable room for a reasonable context window. Single-stream generation is somewhere around 45-60 tok/s depending on card and quantisation. That number is the one everyone quotes and it is misleading, because with a proper serving stack and continuous batching you get many times that in aggregate throughput. If you benchmark with one request at a time and conclude self-hosting is slow, you have benchmarked the wrong thing.
Quantisation notes for a classification workload specifically: Q4 is generally fine, Q8 barely differs from full precision for this kind of task, and your accuracy is far more sensitive to prompt structure than to the last bit of precision. Build an eval set of 200 labelled documents before you touch any of this and score every option against it. Otherwise you are choosing infrastructure based on how the output feels, which is how people end up paying more for worse results.
Context window is the thing that will actually bite you on 24GB. KV cache grows with context and concurrency, so long documents plus batching is what pushes you into out-of-memory, not the weights.
One caveat on the onEvent shape: keep it per-screen rather than one app-wide event type. A single giant sealed interface for the whole app makes every leaf theoretically able to trigger anything, and that does eventually hurt.