Vibes DIY
Vibes DIY / Blog
From the build log

The meter read zero

The day we flipped VibesDIY/vibes.diy from public to private, nothing broke. Every test still passed, every deploy still shipped, every scheduled job still ran. What changed was invisible until the first invoice: a handful of things that had been quietly wasteful for months were now quietly wasteful and metered. GitHub's free tier for public repos had been absorbing the waste, so the meter read zero. It wasn't zero. It was unmetered.

A vintage analog electricity meter in a violet duotone wash, overlaid with the line 'reads 0 ≠ costs 0'.
A meter reading zero doesn't mean nothing is flowing — it means nothing is counting. Going private connected the counter.

What follows is the cleanup that came out of that transition. None of these are clever bugs. Every one is a small, boring waste that survived precisely because the thing that would have surfaced it — a number going up — was pinned at zero.

A rebase-merge that dodged its own green check

Our deploy gates are supposed to be cheap: if a commit already carries a green compile_test check, skip the full test suite and ship. Sensible — don't re-run 4 shards of tests on a SHA that already proved itself.

Except we rebase-merge. A rebase-merge rewrites history, so the commit that lands on main gets a brand-new SHA. The green check lives on the PR-head SHA — the one that never actually lands. So the gate looked for a green check on the merged commit, found nothing, and re-ran the entire suite at ship time. Every ship. It had been doing this for as long as we'd been rebase-merging, and on a public repo that re-run was free, so no one ever saw it.

The fix is a fallback: when the merged SHA has no check of its own, resolve it back to its PR through the commits/{sha}/pulls API, and reuse the green from that PR's head. If it can't prove greenness that way, it fails open — runs the real suite rather than shipping unverified. Belt reconnected, and the suite stops re-running on every single deploy.

A cron that existed only to cost money

Our social-mention builder — the lane that turns an @-mention into a built app — isn't fully provisioned yet; it's waiting on some secrets. But its workflow was already wired with a */10 schedule. So every ten minutes, around the clock, a runner spun up, the job hit its configured=false guard, and exited a few seconds later having done nothing at all.

A no-op job still costs a runner spin-up. Hundreds of them a month, each doing exactly nothing. The fix is embarrassingly simple — comment out the schedule: block, keep workflow_dispatch so it can still be triggered by hand, and make "re-enable the cron" the documented last step of going live:

yamlon:
  # Re-enable when the mention-builder secrets are provisioned (last step of go-live).
  # schedule:
  #   - cron: "*/10 * * * *"
  workflow_dispatch:

The lesson that generalizes: a config gate inside the job body doesn't save you anything, because the runner is already running by the time the gate says "not today." If a feature is dark, gate the schedule, not just the work.

A deploy that built the app twice

A prod ship took about twelve minutes — and wrangler didn't push a single byte to Cloudflare until minute eleven. Most of the deploy was spent before anything was uploaded.

Get posts like this in your inbox

One email field. Real updates. No algorithm required.

The reason, once we diffed the log timestamps: the deploy compiled the whole app twice. One step ran a plain pnpm run build; a later step ran deploy:prod, which does its own react-router build && vite build with the real CLOUDFLARE_ENV set. The first build had no environment, so its entire output was thrown away and rebuilt from scratch with the right one. A full redundant compile, hiding in plain sight as a "belt and suspenders" build step.

The obvious fix — delete the first build — has a trap that a reviewer (Charlie, with Codex) flagged: if you let deploy:prod be the only build, the sole compile now happens after the database migration and secret upload have already run. A react-router/vite failure that a type-check can't catch would then leave your remote schema and secrets mutated with no matching worker deployed. The right shape is one env-aware build up front as a preflight, then a wrangler deploy with no rebuild: a single compile and fail-before-you-mutate.

An unminified worker on the startup-CPU cliff

The prod queue-consumer worker started failing its first deploy with Cloudflare error 10021: Script startup exceeded CPU time limit, then passing on retry — the kind of flake you're tempted to shrug at.

It wasn't the startup logic. It was the startup parse. wrangler deploy doesn't minify by default, so the worker shipped as roughly 7 MB of unminified JavaScript that V8 had to parse at isolate boot — and parsing that much source is enough to blow the startup CPU budget on a cold deploy. One line fixed it:

toml# wrangler.toml
minify = true

That cut the upload by roughly half (about 7.2 MB down to 3.7 MB), which roughly halved the boot-time parse and put the deploy comfortably back under the cliff. Startup-CPU limits get discussed as "move work out of module scope," but bundle parse cost is the sneaky other half — a megabyte you never even execute still costs you at boot. (There's a sibling catch: Vite defaults build.minify to false for SSR builds, so the much larger react-router SSR worker is unminified too — but that one's on the user-facing render path, so it earns its own carefully-validated change rather than a ride-along.)

A lockfile check running once per shard

Profiling our roughly eight-minute CI run turned up pnpm dedupe --check — a whole-workspace re-resolution, about twenty seconds, that asserts the lockfile has no dedupable duplicates. Useful check. The problem was where it lived: in the shared actions/base install step that every job pulls in. So it ran, identically, in all four test shards and the publish build and the checks job — roughly six copies of a check whose answer cannot possibly differ between jobs, four of them sitting right on the critical-path test shards.

Gating it on the run-checks path collapses it to a single run, shaving that twenty seconds off each shard's wall time with zero loss of coverage. A shared composite action is a great DRY win for setup — but it also silently fans a check out across every matrix leg. The tell is a repo-wide, job-invariant check buried in a step that every job runs, and it stays invisible until you diff the per-step timings across the fan-out.

A test pool sized for cores we no longer had

Here's the one that made the theme explicit. When the repo was public, GitHub handed us 4-core / 16 GB standard runners for free. Going private drops standard runners to 2-core / 8 GB. Our heavy browser-test project was pinned at maxWorkers: 3 — a number set back when there were four cores to spread across. On two cores it looked like obvious oversubscription: three headless Chromiums, two cores. "Just lower it to match the hardware" was the tempting one-liner.

So we measured instead of guessing: a throwaway workflow_dispatch probe ran just that one project in isolation, sweeping maxWorkers from 1 to 4 with several reps each, reading back wall-clock and flake rate per value. The result was the useful part — wall-clock was flat across the entire sweep. This project is setup- and async-bound, not CPU-bound; it spends almost all its time rebuilding browser contexts rather than computing, so worker count buys no speed at all. There was no faster number to find. The knob's only real effect was on an isolate: false state-bleed flake — fewer workers pack more files into each one, so more state leaks across files — and that flake was better fixed at its source, which it was. So maxWorkers stays at 3, now carrying a comment that says outright it was never a speed lever.

The lesson isn't "size parallelism to the hardware you can afford" — it's subtler than that. The free-to-paid transition is what prompted the re-measurement, but the measurement's payoff was proving the knob didn't matter on the new runner (and hardening the leak in passing), not discovering a magic worker count. Sometimes reading the timeline tells you the thing you were about to tune isn't the constraint at all.

Costs that read zero aren't zero

Every fix here is small. The pattern behind them is the actual point: going free-to-paid is a performance event, not a billing event. All six of these were live for months. All six were genuinely free, right up until the moment they weren't — and none of them showed up as a regression, because a metering blind spot doesn't trip alarms. There's no test for "this was fine and still is, but now it's on the invoice."

The move that surfaced all of them was the same: stop reading the config and start reading the timeline. Diff the build-log timestamps against the wrangler-upload timestamps and the double build is obvious. Diff per-step timings across the shard fan-out and the sextupled lockfile check jumps out. Watch the runner spin-up count and the dark cron is right there. The config looked reasonable in every one of these cases. The timeline didn't.

So when you flip a switch that starts counting something that was free before — a private repo, a paid tier, a metered API you used to hit under someone else's quota — treat it as a profiling exercise, not a line item. The costs that read zero were never zero. They were just waiting for something to connect the meter.

Build something that ships itself

Every vibe deploys on push — no runners to tune, no cron to dark. Prompt an app and watch it go live.

Start building →

Enjoyed this? Get the next post by email

One email field. Real updates. No algorithm required.

Prefer a feed? RSS · Atom