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.
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 →