Feature Flags Best Practices Without the Maze

A feature flag starts as a kindness. You want to ship dark, turn it on for staff, then for one percent of users, then for everyone, then delete it. Six months later the flag is still there, it defaults differently in staging than in production, two teams own overlapping names, and a customer is on an old code path nobody can find in the repo because the if is nested under another if that checks a cookie.

Feature flags best practices are not a vendor tutorial. They are how you keep a toggle from becoming a second, undocumented product. Used well, flags let you separate deploy from release, abort a bad idea without a rollback of the database, and test in production without betting the whole customer base. Used badly, they are config soup with extra steps: the same class of mess as a copied .env (see environment variables, secrets, and the config mess), except the blast radius is user-visible behavior.

This is a practical bar for flags: when to use them, how to name them, who can flip them, and how they die.

What a flag is for

A flag is a runtime choice you are willing to change without a deploy. Release a feature to 5% of sessions. Give support a kill switch when a partner API starts returning garbage. Let staff see a new checkout on production data. Run an experiment with a clear metric and a clear end date.

A flag is not a substitute for a configuration value that should have been an environment variable. Log level, pool size, and API_URL are config. They should not require a dashboard and a user id. If changing it should restart the process, it might not be a flag.

A flag is not a long-term way to keep two products in one codebase. If “enterprise” and “basic” are permanently different, that is a product plan, a permission, or a separate service. A flag that has lived for two years is a dimension of the domain you refused to name. Name it. Then you can test it.

If you cannot say how the flag dies, you do not have a flag. You have a fork.

The questions before you add one

What happens when it is off? The old path must still work, or you do not have a flag, you have a half-deploy. If off means a 500, you shipped a landmine.

What is the unit? User, account, request, session, region. Flags keyed on user and checked on a shared CDN cache will leak. Flags keyed on request and stored only in a cookie will surprise the mobile app. Say the unit in the name or in the doc. Missing a dimension is how user A sees user B’s new billing page. Caching is the same lesson with a different hat.

Who is allowed to turn it on in production? If the answer is “anyone with the dashboard,” you will find out during a demo. If the answer is “only platform,” product cannot ship. Pick a rule. Write it down. A Slack message is not a rule.

How do you know it is on for a given request? Logs should include the flag key and the variant. At 2 a.m. you will not remember that NEW_CHECKOUT was 15% in eu-west-1. If you cannot see it, you cannot debug “it works on my flag.”

Name flags like you will have to grep them

flag1, newThing, and checkout_v2_final_REAL are how you get archaeology. Use a boring pattern: checkout.stripe_elements.rollout or ops.payments.kill_webhooks. Include the surface. Include whether it is a rollout, an experiment, or a kill switch. Those are different lifetimes.

Kill switches should sound like kill switches. Experiments should sound like experiments. If you use one dashboard blob for both, people will “try 50%” on a kill switch and take payments down with a shrug.

Put the owner in the ticket, not only in a wiki. When the owner leaves, the flag does not become folklore. It becomes a scheduled deletion or a named product dimension.

Keep the flag in one place in the code

The cheap mistake is sprinkling if (flags.x) through twelve files. You will miss the thirteenth. Prefer one branch at the edge: a page, a handler, a job entry. Behind that, call a function that already assumes the new world, or the old world. Two complete paths are easier to delete than a maze of partial ones.

Do not invent a parallel type system. If the new path needs a field the old path cannot see, you are a data migration, not a flag. Flags on the read path with two write schemas is how you get rows that only one version of the app can load. That bug looks supernatural. It is not. You stored two products in one table and used a boolean as the schema.

Feature flags in the frontend are public. Anything in JavaScript can be flipped by a determined user. Authorization still belongs on the server. A flag may hide a button. It must not be the only thing that protects an admin endpoint. Treat client flags as UX, not as security. Authentication basics still apply; a toggle is not a permission.

Defaults, environments, and the staging lie

Default off for rollouts. Default on for kill switches that mean “normal operation.” Getting this backwards is a classic outage: you deploy, the kill switch defaults off, payments stop, and the dashboard is the only thing that can save you while the dashboard depends on the thing you killed.

Staging should not be a random subset of production flags copied from a screenshot. You want a known matrix: the default production-off set, plus a staff user that has everything on. If staging is “whatever we clicked last Thursday,” you are not testing the off path, which is the path most users will hit on release day.

Do not copy production flag state into local development as a surprise. Developers will build against the on path for weeks and never compile the off path. Then the off path is what you ship to 95% of users. CI should run both, or run the default, and you should know which.

Percentage rollouts are still production

Five percent of a large customer base is a lot of people. They still open tickets. They still chargebacks. A percentage is not a shield from quality. It is a way to bound the blast radius while you watch the metric you named in advance: error rate, latency, conversion, refunds. If you have no metric, you do not have a rollout. You have vibes with extra infrastructure.

Ramp in steps you can reverse. 1%, 5%, 25%, 100% is a story you can tell. 0 to 100 because a demo is tomorrow is how you write the incident doc. Stick to one change at a time. A flag plus a migration plus a cache TTL change is three incidents wearing one pull request.

Sticky assignment matters. If a user is in 5% on Monday and out on Tuesday because you hashed on a rotating cookie, they will see a flickering product. Hash on a stable id. Document the id. If you have logged-out users, you do not have a stable id; you have a mess. Decide.

Cleanup is part of the feature

The flag is not done when it hits 100%. The flag is done when the old path is gone, the dashboard entry is archived, and grep returns one historical comment. Put a date on the ticket when you create the flag. A month is generous for a rollout. Experiments get a planned end. Kill switches may live longer; they still need an owner and a review.

If you are afraid to delete the off path, that is information. Either the off path is still serving users, or nobody understands the on path well enough to trust it. In both cases, leaving the if forever does not make you safer. It makes the next change twice as expensive.

Code review should treat a new flag like a migration: where is the delete plan? A PR that adds a flag with no owner and no expiry is how the maze starts. Reviewers can ask. Authors can put the date in the description. This is cheaper than a quarterly “flag audit” that nobody attends.

Debugging “it works on my flag”

When a bug is only on for some users, the flag is a suspect, not a superstition. Check: which key, which unit, which environment, which default if the service is down. Flag services fail. Your app needs a defined fallback. Fail closed for new risky features. Fail open for a kill switch that must not take the site down if Redis blinks. Write that down per flag class. Do not improvise at 2 a.m. See how to debug production when you cannot reproduce it locally.

Support needs a way to see the variant without asking an engineer to query a dashboard they cannot access. A debug header in staging, an internal admin page, a note in the user record. If support cannot answer “are they on the new checkout,” they will file “website broken” and you will reproduce the old path all afternoon.

Do not debug flags only in the browser. CDNs, app caches, and mobile clients cache HTML and config. Incognito does not isolate Redis. If the page is stale, you will swear the flag is off when it is on, or the reverse. Check the cache key includes the variant if the HTML is variant-specific. Often you should not cache personalized HTML at all.

Experiments are not rollouts

An experiment has a hypothesis, a metric, a sample, and a stop. A rollout has a safety plan. Mixing them is how you leave 10% of users on a losing variant because “the experiment is still running” in a spreadsheet nobody owns.

Do not experiment on irreversible side effects without a story. Charging a random 10% a different fee can be illegal, not just rude. Permissions, prices, and deletion are poor experiment surfaces. If you must, get a human who is not you to say yes, in writing.

A small default that keeps you honest

Use flags to separate deploy from release, to kill a bleeding integration, and to test a change on production data with a bounded audience. Put the branch at the edge. Name the flag so grep works. Log the variant. Default safely. Run the off path in CI. Give it an owner and a death date. Do not use flags as a permissions system, a schema, or a second product.

If the dashboard is more interesting than the code, you have too many flags. Delete some. The goal is not a museum of toggles. The goal is to ship, watch, and then have one path again.

Artikals will keep treating runtime control as a bug you schedule, the same way we treat caches. Schedule the flag on purpose. Schedule its funeral on purpose. Then you get the benefit without the haunted house.

Leave a Reply

Your email address will not be published. Required fields are marked *