Feature flags are useful until they quietly become architecture. I have seen a checkout flag live long enough that new engineers stopped asking whether it could be removed. Support learned to say “some accounts still use the old checkout.” Tests covered both flows. Analytics had two funnels. The original rollout ticket was closed months earlier.
The flag was no longer a release safety tool. It had become a product rule without the documentation that product rules normally deserve.
Store lifecycle data next to the flag
A flag definition should carry more than a name and a default value. At minimum, store owner, purpose, removal condition, and the date it was last reviewed.
export const checkoutUsePaymentIntentFlow = defineFlag({
key: "checkout_use_payment_intent_flow",
defaultValue: false,
owner: "payments-platform",
purpose: "Migrate card checkout from legacy charge API to payment intents",
removeWhen:
"100% rollout for paid accounts, seven days of stable auth and payment error rates",
lastReviewed: "2026-06-03",
});
This looks small, but it changes review conversations. A pull request that adds the flag must also explain when the flag stops being useful.
Name the decision, not the mood
Names like newCheckout, temporaryFlow, v2, and enableExperiment age badly. They say how the team felt during the rollout, not which behavior is controlled.
Better names describe the decision:
checkoutUsePaymentIntentFlow
invoiceExportUseAsyncJob
searchReadFromOpenSearch
profileEditorAllowAutosave
Those names are longer, but they make cleanup safer. When a developer searches for the flag six months later, the flag still explains the branch it controls.
Separate rollout flags from durable rules
Some conditionals are not temporary. Plan limits, regional compliance, account entitlements, admin overrides, and migration exceptions may be long-lived product behavior. They should not masquerade as rollout flags.
A useful distinction:
| Conditional | Better home |
|---|---|
| Gradual rollout of a new renderer | Feature flag |
| Customer has paid export access | Entitlement or plan config |
| EU tenant uses region-specific processor | Product or compliance configuration |
| One enterprise account stays on old schema | Compatibility rule with owner and sunset date |
If a “temporary” flag survives because one customer cannot move, the team should rename the situation. It is now compatibility work, not rollout work.
Make cleanup part of the launch ticket
Before enabling a flag, write the cleanup diff in plain language:
After 100% rollout:
- remove legacyChargeCheckout.ts
- remove checkout_use_payment_intent_flow checks from web and API
- delete old funnel dashboard panel
- rewrite checkout contract tests to cover only payment intents
- keep billing_provider_kill_switch until provider migration is complete
This prevents the cleanup from becoming a vague “remember later” task. It also reveals flags that cross too many layers. If the same rollout touches frontend, API, worker, and database code, those layers need a shared rollout state and a shared rollback plan.
Test both sides, then delete one side
During rollout, both flag states need coverage. After rollout, keeping old-path tests forever can be misleading. The suite may pass while the product has already decided that the old path is gone.
The exception is a real compatibility rule. In that case, test it with an explicit fixture:
it("uses legacy export schema for accounts pinned to compatibility mode", async () => {
const account = await createAccount({ exportSchemaMode: "legacy-v1" });
const result = await exportInvoice(account.id);
expect(result.schemaVersion).toBe("legacy-v1");
});
That test says why the branch remains. A generic flag-on/flag-off test usually does not.
Run a stale flag review every release cycle
Small teams can manage stale flags with a markdown table. Larger teams may need a flag service, but the questions are the same:
Flag: checkout_use_payment_intent_flow
Owner: payments-platform
Current state: enabled for 100% paid accounts
Disable impact: paid checkout falls back to legacy charge API
Removal condition: stable for seven days after enterprise rollout
Next action: delete old checkout path by 2026-06-21
The goal is not to ban flags. It is to keep release tools from becoming undocumented architecture. A flag with no owner and no removal condition is not harmless. It is a second product path waiting for someone else to rediscover it.