...
Back

Hiding the Button Is Not Closing the Gate

We turned a feature off by removing its entry point. Users found the feature anyway — as a button that did nothing. A note on what a feature flag has to cover to actually be off.

Hiding the Button Is Not Closing the Gate

Hiding the Button Is Not Closing the Gate 🚪

We needed to disable a gate in one of our apps — temporarily, pending a decision. The change was one line: stop rendering the entry point. Ship it, move on.

What users got was worse than either state. The feature was still reachable through paths that did not go through the hidden entry point, and every one of those paths ran into an enforcement check that was still active. The visible result was a button that did nothing. No error, no explanation, no way forward. "Disabled" had become "broken," and broken is the one outcome neither option was supposed to produce.


A flag has three surfaces and they are usually different code

Presentation — is the entry point visible? This is the one everybody changes, because it is the one you can see.

Enforcement — does the check still run? A gate that still evaluates while its UI is hidden produces exactly our dead button. Users who arrive by another route — a deep link, a saved state, a path from a different screen — hit a wall with no explanation, because the explanation was in the UI you removed.

State — what happens to people already on the other side? This is the one that generates support tickets. If a gate is disabled, does someone who already passed it keep their access? Can they still sign out? Can they still delete their account? Disabling an entry point must never disable an exit.

A flag that only covers presentation is not a flag. It is a cosmetic change with an enforcement bug attached.


The rule that would have saved us

Disabling a feature means every path to it produces a coherent outcome — not that one path is hidden.

Coherent has three acceptable shapes, and which one you pick is a product decision that should be made explicitly:

  1. Transparent — the feature works, it is just not advertised. Enforcement off, entry point hidden. Fine for soft launches.
  2. Explained — the feature is genuinely off and says so. Enforcement on, with a real message: unavailable, why, what to do. This is almost always the right answer for a user-facing gate.
  3. Absent — no path reaches it at all. Requires actually auditing the paths, which is the work people skip.

What is never acceptable is the fourth shape we shipped: entry point hidden, enforcement live, no message.


Flags accumulate, and that is the real cost

Every flag doubles the state space of the code it guards. Two interacting flags is four configurations, of which you test two. The untested combinations are where the strange bug lives, and it will be reported by a user whose account happens to sit in one of them.

Worse, flags outlive their reasons. A flag added for a launch is still there a year later, its default now load-bearing, and nobody remembers whether the other branch still works. At that point it is not a switch — it is dead code with a plausible-looking name.

What has worked for us:

  • Write the exit condition in the same commit that adds the flag. One line: remove after the store review clears or remove when the provider quota is raised. A flag without an exit condition is permanent and should be reviewed as permanent.
  • Default to the state you intend to keep. The flag should read as "the current answer plus an escape hatch," not as a coin balanced on its edge.
  • Test both branches or delete one. If nobody has exercised the off-path in six months, it does not work. You have a constant with extra steps and a false sense of reversibility.
  • Prefer deleting to flagging. A flag is the right tool for a decision that is genuinely pending. It is the wrong tool for code you are reluctant to remove — that is what version control is for.

The part that generalizes

The dead button was not a UI bug. It was a mismatch between two layers that each did something reasonable in isolation: the view stopped offering the feature, and the enforcement kept refusing it. Neither was wrong on its own.

Any time behavior is controlled from more than one place, the failure mode is not that one place is wrong — it is that the places disagree, and the disagreement renders as nothing at all. Which is why the test to run is never "did I hide it," but "what does a user who arrives from somewhere else see?"