•
5 min read
name your feature flag for what happens when it cannot be read
feature-flags mobile reliability

A mobile app needed a single switch that removes an old feature everywhere for a tenant that has migrated to its replacement. The migration is one-way per tenant. The old feature’s surface in the app was wide: a main tab and a guest tab, ten global routes, twelve deep-link branches, two share sheets, a contact-detail tab, inbox events, notification settings, four invite-redeem paths, and a handful of smaller entry points.

The obvious design is an enable-old-feature flag. The right design is the opposite, and the reason is what the flag library does when it cannot reach the flag service.

polarity

The client flag hook falls back to the declared default whenever the flag service is unreachable or has not yet identified the user. That happens on every cold start, for the seconds before identification completes, and for the whole duration of any outage of the flag service.

With enable-old-feature defaulting to off, every one of those windows hides the feature for every tenant, including the ones that never migrated and use it daily. The failure mode is a disappeared feature.

With disable-old-feature defaulting to false, the same windows show the feature. The failure mode is today’s behavior. A migrated tenant briefly sees a feature they moved away from, which is harmless. An unmigrated tenant never loses anything.

Name the flag for what happens when it cannot be read. A kill switch fails toward the old behavior. An enable switch fails toward nothing.

The same polarity question came up on the web side of the same migration. There, a tenant with the new surface enabled saw both menu entries and could open both, each rendering a different half of the same data, because the new surface was gated on an enable flag and the old one on the entitlement alone. The migration window also needed “both off”, which one flag cannot express. The fix was the same inverted-polarity kill switch as a second flag, with one more property: it could be merged before the flag existed in the flag service, because an unresolvable flag reads false and the old surface stays reachable.

three enforcement layers, deliberately redundant

Hiding entry points is the primary mechanism. Two more sit behind it.

A guard wrapper on the old feature’s route entries toasts and navigates away. It catches a missed entry point, and it catches a stale push notification that deep-links into a route the user should no longer reach. Routes stay registered so that a stray navigation call hits the guard instead of becoming an unhandled action.

The deep-link parser resolves the old feature’s paths to the default tab. The shared deep-link route also drops the old feature’s tab entirely when the flag is on, because the navigation library rejects an entire state that names a route the navigator does not have. Leaving the tab in the shared route would have broken unrelated deep links, for properties and contacts and updates, not only the ones for the old feature.

the share sheets

The two share sheets had a contact list. Picking a contact called a find-or-create for the old feature’s container and shared into it. Keeping the contacts while hiding only the old feature’s rows would have left a working path to create one.

The sheets collapse to link, message, and email. Consumers with no containers already saw exactly that collapsed sheet, so the disabled state reuses a designed state rather than inventing one. The share mutations also refuse with a toast as a backstop.

one pure function

On web, all gating resolves through one pure function:

resolveSurfaces({ entitlement, disableOld, enableNew })
  // -> { newSurface: boolean, oldSurface: boolean }

newSurface is entitlement and enable-new. oldSurface is entitlement and not disable-old and not enable-new. A hook wraps it for components, and the navigation resolver calls it through one additive field, so the route, the left-nav config, and the in-page cards stop each re-deriving the composite of one entitlement and two flags.

The hook returns both surfaces closed while entitlements are in flight, without consulting the entitlement list. The list is documented as empty during that window, which would resolve false anyway. But the data layer can report loading while still holding the previous result, and the app has a tenant-switch flow, so a stale entitlement from the previous tenant could briefly grant a surface the new tenant lacks. Closed-while-loading costs a frame. Open-while-loading costs a surface the tenant did not buy.

what was left alone

Tightening the shared navigation resolver’s boolean coercion so nav and routing agreed was rejected, because it would change behavior for every other menu item to defend against one misconfigured flag. Centralizing on the shared predicate removed the divergence for these two items without touching the shared coercion. A dead flag that existed in the flag service with zero references in the code was left as dead config rather than cleaned up in the same change.

Feature flags are usually discussed as a rollout tool. The design work is in the failure modes: what the client sees when the flag service is down, what a stale cache grants, what a deep link reaches. Get the polarity right and most of those answer themselves.