← All notes
Product & Platform Engineering7 min

Staging that lies is worse than no staging

"It passed staging" is meant to mean the change is ready. In practice it often means the change survived an environment that no longer resembles production: different secrets, a quieter third-party sandbox, a smaller dataset, and a feature-flag default nobody reset. We have shipped through that false confidence, and the failure mode is always the same. Staging did not catch the bug because staging was answering a different question.

What staging is quietly allowed to omit

The dangerous staging environment is not the broken one. It is the one that looks healthy while skipping the parts of the path that only exist in production. A Sanity read token missing on a preview branch. A Shopify webhook signing secret that differs from the live shop. A Stripe test clock that never exercises the same failure codes. A CDN cache that is empty on every staging hit and full in production.

None of those gaps show up as a red build. The page renders. The checkout button works against a sandbox. The webhook handler accepts a signed request from a tool you control. Then production receives a real order, a real signature, a warm cache, and a content shape the seed data never had.

We treat that class of miss as a design problem, not a bad luck problem. If an environment cannot exercise a seam, it should not be allowed to claim that seam was tested.

Shared staging drifts by default

A long-lived staging box accumulates state the way a shared kitchen accumulates dishes. Someone toggles a flag for a demo and leaves it on. A migration runs once and never gets cleaned up. A seed script inserts documents with ids that production does not use. A hotfix lands in production and never gets backported. Two weeks later, green on staging means green against yesterday's leftovers.

BuilderHelp made this concrete for us. Web, marketing, and mobile are separate repos and separate deploy surfaces. A "staging" that only exists for the Next.js web app does not rehearse the mobile session path, the marketing content publish, or the parent repo that pins which commit of each surface is live. Calling that one URL the rehearsal for the product is how you get a release that is half-checked.

When we need a pre-production check now, we prefer an environment built from the branch and torn down afterward — a Vercel preview, an EAS build from the PR, a Sanity dataset or perspective that matches the content rules for that surface — over a shared box that has been absorbing every experiment since the last reset.

Third-party sandboxes are not production with a quieter voice

Commerce and payments are where this bites hardest. Shopify's test shop, Stripe's test keys, and a PrintNode sandbox will accept traffic that production will reject, and reject traffic that production will accept. Al's Flowers taught us that early: an order ingest that looks fine against a hand-fired webhook can still fail when the live shop retries, signs differently, or delivers the same order twice while the printer is offline.

The same pattern shows up on content sites. A local Sanity dataset with ten tidy documents does not expose the empty optional fields, the oversized image, or the draft that an editor left hanging. Our own site ships a committed JSON seed so a preview without CMS secrets still renders — which is good for clones and forks, and useless as proof that the production dataset, tokens, and perspectives are wired correctly.

So the rehearsal has to name the third party and the mode. "Staging passed against Stripe test" is a fact. "Payments are ready" is a different claim, and we do not let the first sentence stand in for the second.

Make the expected differences explicit

Perfect parity is not the goal. Production has real traffic, real data volume, and real money. Staging should be smaller. What it must not do is differ in silence on the dimensions that decide whether a feature works.

We keep a short allowlist of intentional differences: instance size, log sampling, sandbox credentials, noindex headers, draft perspective on preview. Everything else — schema version, env var names, webhook verification, auth cookie rules, feature-flag defaults for the path under test — is expected to match. If it cannot match, the gap goes in the release notes for that change, not in a tribal memory.

Startup validation helps more than another dashboard. Fail the boot when a required variable is missing instead of falling back to a default that makes the environment look fine. A loud missing secret is cheaper than a quiet wrong one.

Trust the path you can name

Before we treat a staging pass as evidence, we ask what path it actually ran. Same artifact promoted, or a differently built image? Same content shape, or a curated seed? Same third-party mode, or a sandbox that cannot fail the way production fails? Same flag and auth state a real user will hit?

If the honest answer is "most of it," staging is still useful for catching obvious breaks. It is not a rehearsal. The remaining risk belongs in production checks: a canary, a smoke path against live config, and enough observability to see the first bad response before the whole surface is soaking in it.

The rule we write into the work is simple. An environment that cannot lie about what it covered is more valuable than one that is always green. Name the gaps, keep the shared box from pretending it closed them, and stop letting "passed staging" mean more than the path you can point to.

Have something to build?

Tell us what you're working on and we'll tell you honestly whether we're the right fit.

Work with us