Feature Flag Best Practices: 14 Rules That Actually Hold
Feature flag best practices with the concrete failure each one prevents: naming, cleanup, flag types, testing, evaluation, governance, and SDK fallbacks.
Published:
14 Feature Flag Best Practices That Hold Up in Production
Most feature flag best practices lists open at the naming convention. Start one step earlier. Decide a flag’s type and its death condition before you write the first if statement. Everything else follows from that: how long the flag lives, who owns it, where it is evaluated, what value it serves when LaunchDarkly or Unleash or your own service is unreachable, and whether a stale-flag alert should ever fire for it. A release toggle and a permissioning toggle are different objects that happen to share an API, and applying one rule set to both is the root cause of most flag debt.
Each of the 14 feature flag best practices below comes with the concrete failure it prevents and a sentence you can paste into an engineering standard.
Feature flag best practices at a glance
- Classify the flag first. Release, experiment, ops, permissioning or entitlement.
- Give every flag a death condition, not an expiry date: a named observable event plus an owner.
- Name flags for a non-engineer.
<domain>.<type>.<feature>-<ticket>, globally unique, never reused. - Decouple the toggle point from the toggle decision.
- Place the toggle at the edge or in the core deliberately.
- Design for the flag service being down. Bootstrap, cache, evaluate locally, choose each default on purpose.
- Evaluate server-side. Send browsers evaluated booleans, not your targeting rules.
- Test the two configurations you will ship, not all 2^n.
- Keep rollouts sticky by hashing a stable identifier, and write the ramp schedule first.
- Don’t use flags for configuration. If flipping it needs a restart, it is config.
- Be careful using flags as entitlements. Billing is the source of truth.
- Control who can change production flags, and log changes into the deploy timeline.
- One flag, one decision.
- Instrument flags with evaluation counts, last-evaluated timestamps and variant-segmented error rates.
- Watch total cost. Payload size, memory per process, and your vendor’s pricing axis.
Fifteen items: fourteen rules plus the classification step they all depend on.
This article is desk research. Every external claim links to a primary source: Martin Fowler and Pete Hodgson for the taxonomy, vendor documentation from Unleash, Contentful, Harness and Octopus Deploy where a vendor describes its own product, the SEC for Knight Capital, and Hacker News threads where practitioners argue about what the vendors skip. Where a feature flag best practices question needs hands-on testing, such as SDK behaviour under a network partition, this says so and explains how to test it.
First, classify the flag into one of five types
Fowler’s feature toggles article, written with Pete Hodgson and published in October 2017, defines four categories: release, experiment, ops and permissioning (martinfowler.com/articles/feature-toggles.html). A fifth keeps surfacing in practitioner threads and on no ranking page: the entitlement flag, gating a paid plan tier. Fowler folds that into permissioning. It behaves differently enough under failure to deserve its own row, and that split is this article’s synthesis rather than established taxonomy.
Two axes predict everything: longevity and dynamism.
A release toggle is transient and static. It changes when a human ramps it, and Fowler’s guidance is that it should rarely outlive a week or two, which is what makes flags the safety mechanism underneath trunk-based development. That flag can be a plain if/else over a config value refreshed at deploy time. A permissioning toggle sits at the opposite corner: the decision changes per request and per user, and Fowler puts premium-feature gating on the scale of multiple years. It needs per-request evaluation, a targeting engine and an audit trail.
Experiment toggles have a lifetime defined by statistics rather than the calendar. Configuration must stay frozen long enough to reach significance, hours to weeks depending on traffic, and a run left going past that starts absorbing other changes to the system. Size the run first; our note on A/B test sample sizes covers the arithmetic.
Ops toggles are kill switches, load-shedding controls and circuit breakers. They are the reason “delete every flag” is bad advice. A kill switch that has been off for eight months is insurance. Unleash makes the same exception for internal debug flags (docs.getunleash.io/guides/feature-flag-best-practices).
Mix all five under one policy and you get a cleanup rule everybody ignores, because everybody can see it is wrong for the kill switch and wrong for the premium gate. Once a policy is visibly wrong in two cases out of five, it stops binding in the other three.
Flag type decision matrix
| Release | Experiment | Ops / kill switch | Permissioning | Entitlement (synthesis) | |
|---|---|---|---|---|---|
| Typical lifetime | Days to ~2 weeks | Until significance (hours to weeks) | Indefinite by design | Years | Life of the plan |
| Dynamism | Static per deploy or slow ramp | Static during the run | Highly dynamic, flipped in an incident | Per-request, per-user | Per-request, per-account |
| Owner | Feature author / squad lead | Experiment owner (PM or analyst) | Service on-call | Product owner | Billing or platform team |
| Evaluated where | Edge or core, inline OK | Close to the metric emission point | Core, in the hot path it protects | Server-side, per request | Server-side, authoritative store |
| Safe default on no data | Off (old path) | Control variant | Depends on polarity (see rule 5) | Deny | Deny |
| Cleanup trigger | 100% in prod for 7 days | Result declared | Reviewed quarterly, not removed | Plan changes | Plan retired |
| Staleness alerts? | Yes, loudly | Yes, after the run ends | No, suppress | No | No |

Visual needed: The matrix above as a designed table, with a small scatter placing the five types on Fowler’s two axes (longevity on x, dynamism on y). Source lifetimes from martinfowler.com/articles/feature-toggles.html and the kill-switch and debug-flag exceptions from docs.getunleash.io. Label the entitlement column as the author’s synthesis.
1. Give every flag a death condition, not just an expiry date
Every page in this SERP tells you to set expiration dates. Dates fail predictably. The date passes. An alert fires into a Slack channel. Nobody named on it owns the work. Somebody mutes the alert, and now you have a flag and a muted alert.
A death condition names an observable event and a person:
- “Archived when this flag has been at 100% in production for 7 consecutive days. Owner: @jrivera.”
- “Archived when the experiment owner declares a result. Owner: @pmehta.”
- “Never removed. Reviewed each quarter by the payments on-call rotation.”
That third one is why death conditions beat dates. It is a legitimate answer a date field cannot express.
Unleash and Octopus Deploy both frame stale flags as technical debt needing scheduled audits rather than good intentions (Unleash, Octopus). Both are vendor-published. The operational consequence holds anyway: flag removal is a Jira ticket in a sprint, sized and assigned, or it does not happen.
Removing a flag means removing the losing code path. Here is the most common half-finished cleanup: someone deletes the toggle from the dashboard, leaves the dead else branch in the repository, and now the code carries an unreachable path a future reader will assume is live. Vendor code-reference scanners and a nightly grep -r over the flag list both catch this, and either is worth 20 lines of CI.
Archive, don’t delete. The audit trail survives and the name is permanently retired, so it cannot be silently reused. See Knight Capital, below.

Visual needed: Lifecycle diagram, creation to archive, showing the 4 metadata fields captured at creation and the archive step locking the retired name against reuse. No external data required.
Rule text: Every flag records an owner, a type and a death condition at creation. A flag whose death condition is “never” must name a review cadence. Creating a flag creates its removal ticket in the same commit.
Cost, stated honestly: this is overhead on a two-day flag, and teams that enforce it heavily see engineers route around flags with environment variables. Keep the metadata to four fields.
2. Name flags so a non-engineer can find them in six months
Most guides stop at “use a convention.” Use this one:
<team-or-domain>.<type>.<feature>-<ticket>
checkout.release.express-pay-PAY-1421
billing.entitlement.seat-based-invoicing-BIL-882
search.ops.disable-semantic-rerank-SRCH-204
Each segment buys something. The domain prefix makes ownership greppable and gives PagerDuty something to route on. The type segment lets automation apply different staleness rules to .ops. than to .release. without a lookup table. The ticket suffix tells a reader six months later why the flag exists.
Harness supplies the best cautionary example in the field: a flag named NEXT_OLD_GEO3 that actually controlled GDPR-related features for European users, where “Privacy Features, Europe” would have told a reader what it did (harness.io/blog/feature-flags-best-practices). A name that only makes sense to its author becomes an unremovable flag, because nobody else can prove it is safe to delete.
Make names globally unique across the flag service, not per project namespace. Unleash’s argument is organisational: monoliths get split, services get merged, teams reorganise, and namespaced names collide during exactly those events (docs.getunleash.io).
Never reuse a retired name. That is the mechanism behind “we turned on a feature nobody meant to turn on.”
Never build a key by string concatenation. flags.isEnabled("checkout." + region + ".newFlow") defeats every static analysis tool, every IDE reference search and every cleanup script you will write. The key must be a literal grep -r can find. Contentful recommends ALL_CAPS constants for signposting (contentful.com/blog/what-are-feature-flags/), which is fine as long as the constant’s value is a literal string.
Rule text: Flag keys are literal strings, globally unique, and follow
<domain>.<type>.<feature>-<ticket>. Retired names are never reused. Dynamic key construction is prohibited.
3. Decouple the toggle point from the toggle decision
The anti-pattern is the SDK call at the decision site:
// Anti-pattern: magic string, SDK coupling, scope logic scattered
if (features.isEnabled("next-gen-checkout")) {
return renderNewSummary(cart);
}
return renderLegacySummary(cart);
Repeat that in nine places and the flag’s scope is defined by nine independent conditionals. Widening it to cover confirmation emails means finding all nine. Every module that touches it now depends on the flag SDK, so every unit test needs a stub.
Hodgson’s fix is a decision layer with intention-revealing methods (martinfowler.com/articles/feature-toggles.html):
// featureDecisions.js: the only file that knows the flag key
export function createFeatureDecisions(toggleRouter) {
return {
useNewCartSummary: () => toggleRouter.isEnabled("checkout.release.cart-summary-CHK-901"),
includeCancellationLinkInEmail: () => toggleRouter.isEnabled("checkout.release.cart-summary-CHK-901"),
};
}
// call site
if (featureDecisions.useNewCartSummary()) { ... }
Two call sites, one flag, one place to change the mapping. Both methods read the same key today, and that is the point: scope changes in one file. The Python equivalent, a FeatureDecisions dataclass over a ToggleSource protocol, is in the FAQ, because feature flag best practices python is a related search nobody in this SERP serves.
Stronger still is inversion of decision. A function taking new_summary: bool needs no SDK, no stub and no network to test, and a dict-backed fake router covers the decision layer in three lines. For long-lived toggles, drop the conditional and select an implementation at wiring time with a strategy object or a Spring/.NET DI container. The branch happens once at startup rather than on every call.
Rule of thumb: transient release toggles may use inline conditionals. Anything expected to outlive one release cycle gets the indirection. Prescribing a decision layer for a two-day flag is how the pattern gets abandoned.

Visual needed: Paired before/after code panels in JavaScript and Python, annotating the single file that knows the flag key. Pattern credited to martinfowler.com/articles/feature-toggles.html; code original.
4. Put the toggle at the edge or in the core, deliberately
Edge placement means the decision happens in routing or in the UI layer, before the request reaches domain logic, whether that is a Cloudflare Worker, a Next.js middleware or your load balancer. It suits flags gating a whole user-visible surface whose decision depends only on the request. The core stays clean.
Core placement is unavoidable when the decision depends on domain state the edge lacks: the account’s outstanding balance, whether a migration completed for this tenant, the shape of a record only the repository layer loaded.
The cost of core placement is toggle context threading. A flag five layers down that needs user identity forces user context into layers with no business knowing about users. Repository methods grow a user parameter. Value objects grow a context field. When you delete the flag, the parameter stays, because forty call sites pass it.
Name that in review. If the toggle context has to travel more than one layer to reach the toggle point, the toggle point is in the wrong place. Move the decision up to where the context already lives, or pass a boolean down instead of the context.
Decide at design time. Retrofitting placement means touching every layer the context crossed.
5. Design for the flag service being down
Your application must never have a hard runtime dependency on the provider. Unleash frames this as choosing availability over consistency in CAP terms (docs.getunleash.io). A slightly stale value beats a failed request almost every time.
Four mechanisms, all true at once:
- Bootstrap. The SDK starts from a local file or embedded defaults, so it holds a full flag set before its first successful sync.
- Background-synced in-memory cache, refreshed out of band.
- Local evaluation. Targeting rules run in your process against cached config, no per-request network call.
- An explicit per-flag default for the genuine no-data case.
The default-polarity bug nobody writes about
Defaults get written as false because false is what an uninitialised boolean looks like. For a release toggle that is correct: no data means old path.
For a kill switch it can be exactly backwards.
Take search.ops.disable-semantic-rerank. Normal state false, reranking runs. Incident state true, reranking is bypassed. The flag service goes dark, the SDK serves false, reranking runs. Correct.
Now write the same switch as search.ops.enable-semantic-rerank, defaulting to false. The provider goes dark and every process in the fleet silently drops into degraded mode during an incident that has nothing to do with search. You built a kill switch that pulls itself.
Write kill switches so the healthy state is the default. Name them disable-* and default to false, or enable-* and default to true. Pick one and put it in the standard, because the bug is invisible in review when the polarity lives only in the name.
Cold start is the real risk window
An SDK that has not received its first payload serves defaults. That window is short, and it lands when your fleet is most fragile: the seconds after a deploy, when every new process is cold and asking for config at once. Ask concretely, what does a fresh process do in its first 200ms if the provider is unreachable? Block, serve defaults, or serve the bootstrap file? The answer is SDK-specific and version-specific, and it needs testing rather than assumption. See the open questions at the end.
Mobile deserves its own line. An app binary sits behind App Store or Play review, so the fix you would ship in an hour on the server takes days on iOS. Ship generous on-device defaults, cache the last good payload to disk, and expect a long tail of installs evaluating config that is weeks old. Firebase Remote Config exists for this shape of problem, and its 12-hour default fetch throttle is the part to read closely.
Streaming vs polling
Streaming over SSE propagates changes in under a second, which is what an incident kill switch needs. Its failure mode is quiet: a connection that stays open but stops delivering events looks identical to a period with no changes.
Polling accepts up to one interval of staleness, and its failure mode is loud, because a failed poll is a discrete, countable event you can put on a Prometheus counter.
Either way, emit the age of the last successful sync, per process, and alert when it exceeds three intervals. That one metric catches silent-stream failure, expired API keys and network partition. OpenTelemetry’s feature-flag semantic conventions give you a standard attribute set for it rather than a bespoke Datadog tag.
The recurring Ask HN thread on flags in microservices raises the boundary question (news.ycombinator.com/item?id=16619891), and it stays open: evaluate once at the edge and propagate the result, or re-evaluate in each service. Propagating gives one consistent decision per request. Re-evaluating gives each service autonomy and a smaller blast radius.
6. Evaluate server-side, and don’t ship flag configuration to the browser
A client-side SDK fetching raw configuration ships your targeting rules, your segment definitions and the names of unreleased features to anyone who opens devtools. Product names have leaked this way, and so have acquisition plans, visible in a segment referencing a company domain.
Apply least privilege to flag context. The user IDs, email addresses and locations used for targeting should stay inside your perimeter. Evaluate server-side and send the frontend only results (docs.getunleash.io). Under GDPR, in force since 25 May 2018, shipping identifiable targeting context to a third-party provider is a processing relationship you have to document, and it is the first thing a SOC 2 auditor asks about a subprocessor. Local evaluation removes it.
| Architecture | What crosses the boundary | Latency | PII to provider |
|---|---|---|---|
| Backend SDK, local evaluation | Config in, nothing out per request | In-process | None per request |
| Frontend SDK to your evaluation endpoint | Evaluated booleans only | One extra hop | None |
| Edge evaluation (Worker, CDN, Vercel Edge Config) | Evaluated booleans only | Low, geographically close | None if config is edge-cached |

Visual needed: The three architectures side by side, each arrow labelled by what it carries, with the perimeter line drawn where PII stops. Mark which arrow carries raw configuration and which carries only results.
The tradeoff nobody states: server-side evaluation for a single-page React app adds a hop and a bootstrap problem. If the SPA must call your endpoint before first paint, you get a flash of the wrong variant or a spinner. Embed the evaluated flag set in the initial HTML as a JSON blob and have the client SDK start from it.
One scepticism worth holding. “Evaluate server-side” is also the architecture that sells more infrastructure. The privacy argument stands without the vendor. The performance argument is theirs to prove.
7. Test the flag states you will actually ship
The arithmetic is the argument. n independent booleans produce 2^n combinations. Ten flags in one service is 1,024 states. Twenty is 1,048,576. Nobody tests them all.

Visual needed: Two series, 2^n total states against a flat line at 2, for n = 1 to 12, log y. Pure arithmetic, no source needed.
Test the current production configuration and the intended next one. Mid-rollout that is usually all-off and all-on. Fowler makes the point directly: you test the code paths you are going to release (martinfowler.com/articles/feature-toggles.html).
In CI, run the affected suite twice, parameterised by the toggle router:
# .github/workflows/test.yml
jobs:
test:
strategy:
matrix:
flag_state: [current_prod, next_release]
steps:
- run: pytest --toggle-fixture=${{ matrix.flag_state }}
# conftest.py: inject the router, don't mock the vendor SDK
FIXTURES = {
"current_prod": {"checkout.release.cart-summary-CHK-901": False},
"next_release": {"checkout.release.cart-summary-CHK-901": True},
}
@pytest.fixture
def toggle_router(request):
state = FIXTURES[request.config.getoption("--toggle-fixture")]
return DictToggleRouter(state)
Injecting a DictToggleRouter rather than mocking a vendor SDK keeps tests portable across providers, which matters if you adopt OpenFeature’s vendor-neutral API or switch from LaunchDarkly to Flagsmith.
Per-request overrides. A signed header or cookie that forces a flag state for one request lets QA and Playwright exercise a variant in shared staging without changing global config. Restrict it to non-production, or sign it and log every use.
Independence is the assumption that makes this tractable. Two flags touching the same code path are not independent. Practical rule: if two flags appear in the same function, treat them as one test dimension and enumerate that pair’s four states.
Contract for removal: the cleanup PR deletes the flag’s test parameterisation. Otherwise the matrix runs two identical suites forever.
8. Keep rollouts sticky, and understand your bucketing
The same user must get the same variant on every request. Sample randomly per request and the UI flickers, session metrics stop meaning anything, and experiment results become noise.
Stickiness comes from hashing a stable identifier. Fowler’s canonical illustration is a 1% canary cohort selected by a modulo of user ID, consistently receiving the new path while the other 99% stay on the old one (martinfowler.com/articles/feature-toggles.html).
Four things break it in practice:
- The anonymous-to-authenticated transition. A logged-out visitor is bucketed on a device ID, then on a user ID after login, and the variant flips mid-session. Carry the pre-auth ID as the bucketing key, or issue a stable ID at first contact.
- Cross-device. Same person, two device IDs, two variants. Only fixable by bucketing on the account.
- Changing the hash salt mid-experiment. Rebuckets everyone.
- Changing the percentage mid-experiment. Going from 20% to 30% may or may not preserve the original cohort, depending on whether the SDK uses a stable ring. Check before you ramp during a live run.
Document the ramp before you start
A ramp schedule is an artifact, written before the first flip. Our canary release checklist uses the same shape.
| Step | Cohort | Watch | Abort if | Min soak |
|---|---|---|---|---|
| 1 | Internal staff | Error rate, manual QA | Any P1 | 1 day |
| 2 | 1% | 5xx rate, p99 latency | 5xx up >0.1pp in cohort | 4 hours |
| 3 | 5% | + checkout conversion | Conversion down >2% in cohort | 24 hours |
| 4 | 25% | + DB load, queue depth | Queue depth >2x baseline | 24 hours |
| 5 | 50% to 100% | All above | Any of the above | 48 hours at 50% |

Visual needed: Horizontal timeline of the five steps, cohort size on one axis and soak duration as bar length, each abort threshold tagged on its step. Illustrative, not measured.
Naming the abort threshold in advance is the whole point. Decided at 2am with a launch date behind it, the threshold becomes whatever lets the rollout continue.
Fowler distinguishes the canary release, a random cohort, from the champagne brunch, a named internal or beta group. Run the champagne brunch first. Internal users report bugs with context. A random 1% just leaves.
Segment on stable group attributes such as plan tier, region or signup cohort, rather than embedding lists of individual user IDs. Unleash’s reason is mechanical: that list ships in the config payload to every SDK instance (docs.getunleash.io). See rule 14.
9. Don’t use flags for configuration, and know the difference
The test: if flipping it requires a restart or a redeploy, it is configuration. Unleash draws the line on lifetime and runtime mutability (docs.getunleash.io).
| Configuration | Feature flag |
|---|---|
| Database credentials, connection strings | Gradual percentage rollout |
| Port bindings, thread pool sizes | Beta access for a named cohort |
| CORS headers, allowed origins | Kill switch for an expensive subsystem |
| API base URLs per environment | A/B or multivariate experiment |
| Log level at startup | Premium feature gate |
Contentful argues against keeping flags in config files, since flipping one then requires a deploy or hand-editing something in production (contentful.com/blog/what-are-feature-flags/). Right for hand-edited files, too broad as stated, because GitOps flag storage is a different thing.
Flipt serves flag state from declarative backends including Git, OCI registries and object storage; its Show HN drew 137 points (news.ycombinator.com/item?id=41460061). Dorkly is an open-source backend serving LaunchDarkly-compatible SDKs from YAML in a GitHub repo, and drew 304 points (news.ycombinator.com/item?id=40796697). Both give you review, history and rollback free, because the state is a file with a merge history, and a Terraform provider gets you the same property against a hosted vendor. AWS AppConfig and Azure App Configuration sit in the same neighbourhood, with deployment strategies rather than merge commits.
The cost is propagation time and the loss of a non-engineer UI. A support agent cannot open a pull request. If your flags are mostly engineer-flipped release toggles, GitOps is legitimate. If PMs and support need to flip things, it is not.
Fowler’s advice here runs against most vendor content. Prefer the least dynamic configuration mechanism your requirements allow. Runtime dynamism costs testability, and it is not a free default.
Secrets are never flags. Once, plainly.
10. Be careful using flags as entitlements or billing gates
You already have a per-user targeting engine. Gating the premium plan with it takes ten minutes. Fowler classifies this as a permissioning toggle and notes such flags may live for years (martinfowler.com/articles/feature-toggles.html).
Practitioners are asking how, and the Ask HN thread on entitlements and billing reaches no settled answer (news.ycombinator.com/item?id=38907509). No page ranking for this keyword addresses it. What follows is the author’s position.
The case for: one targeting engine instead of two systems that can disagree; plan changes taking effect without a deploy; sales and support granting access themselves, which is a real win for comped accounts and trials.
The case against, concretely.
Failure direction is wrong. Rule 5 says never hard-depend on the provider, and the way to honour that is failing into a cached or default value. An entitlement system failing into “enabled” gives paid features away for the duration of the outage, with no record of who got what.
Flag audit logs are not billing records. Billing needs a durable answer to “what was this account entitled to on 14 March, and who changed it?” Providers keep audit logs of varying retention, sometimes 30 days on entry tiers. A refund calculation needs a record you own.
Pricing. Vendors charge per seat, per monthly active context, or per evaluation. Moving entitlement checks into flags means evaluating on essentially every authenticated request, which changes your bill’s shape from headcount to traffic.
The availability rule contradicts itself here. Entitlements must be authoritative. Flags must be available even when unauthoritative. One system cannot hold both.
The workable split:
- Billing is the source of truth. Entitlements derive from subscription state in your own Postgres, with durable history.
- Flags control the rollout of the entitlement-checking code, not the entitlement.
billing.release.enforce-seat-limits-BIL-882is a good flag.billing.entitlement.user-has-premiumis not. - Short-term overrides live in flags. Trials, comped accounts and demos, with a mandatory expiry, reconciled back into billing nightly.
- If you do gate on flags, invert the default so failure denies access. A paying customer briefly locked out generates a support ticket. A month of free premium for everyone generates a revenue problem you cannot claw back.
11. Control who can flip what, and log every change
Two access questions get conflated.
Who can see flag state should be nearly everyone. Unleash argues for open-by-default visibility so PMs and support can answer “is this user in the new flow?” without filing a ticket (docs.getunleash.io). Hiding state from support moves the question into an engineer’s DMs.
Who can change production flag state should be narrow. In order of value:
- SSO with group mapping, so leaving the company revokes flag access the same day it revokes everything else.
- Environment-scoped permissions. Staging wide open, production restricted. Most flag mistakes happen because the person testing in staging had the same button in production.
- Four-eyes approval on production toggles.
Audit log requirements: who, what, when, previous value, new value, and a free-text reason. Log the approval too, not only the resulting change (harness.io/blog/feature-flags-best-practices).
Flag changes are production changes. Pipe flag events into your deployment feed and your incident timeline, so “what changed in the last hour?” has one place to look. A flip missing from that timeline costs you thirty minutes of an outage.
Design the break-glass route. An approval gate that cannot be bypassed at 3am gets bypassed by sharing credentials, and then your audit log is wrong as well as your process. Give on-call a documented emergency path that logs loudly and triggers review the next working day.
Octopus frames the security risk as misconfigured flags exposing functionality to unintended users (octopus.com). The mechanism is worth stating: a rule matching on an attribute that changes meaning, such as an is_internal field repurposed for partner accounts, widens the audience with no flag change at all. Review targeting rules when the attributes they read change semantics.
12. One flag, one decision
The pattern to avoid is one flag gating a frontend change, a backend endpoint, a data migration and some copy edits, because they shipped in the same PR.
Three things break at once. You cannot attribute a regression to a code path, because four changed together. You cannot roll back partially, so a copy typo forces you to disable the entire feature. You cannot ramp the risky half slower than the safe half.
- One flag per independently-failing component. If the frontend can break without the backend breaking, that is two flags.
- One coordinating release decision on top, expressed in the decision layer from rule 3 rather than as a fifth flag.
- Remove the children first, then the parent.
The counter-force is real: more flags means more combinations, which is rule 7’s problem. Resolve it structurally rather than by merging flags.
Order matters for frontend/backend pairs. Enable the backend flag first and remove it last. Otherwise a UI goes live calling an endpoint that is not there. Schema work follows the same discipline, with expand-contract migrations behind their own flag so the write path and the read path ramp separately, a pattern our guide to progressive delivery covers in detail.
13. Instrument flags so you can prove the rollout is safe
Four things worth emitting per flag:
- Evaluation count by variant. A flag at 100% with zero evaluations means the code path is dead.
- Last-evaluated timestamp. The stale-flag signal grep cannot give you.
- Error rate segmented by variant. Not aggregate.
- The business metric the rollout is meant to move, segmented the same way.
Evaluation counts find the dead flags static analysis misses: the flag exists, the key is referenced, the code compiles, and the calling path was removed two refactors ago. Grep says alive. Telemetry says nothing has evaluated it in 90 days.
Segment your existing Grafana dashboards by variant during a ramp. This is the highest-value instrumentation change available. A 5% rollout failing 100% inside that cohort moves the aggregate error rate by 5 percentage points, which on a noisy service looks like Tuesday. Segmented, it is unmissable.
If you are testing in production, capture evaluation data and connect it to business context, or the exercise is theatre (harness.io/blog/feature-flags-best-practices).
Automate the abort for kill-switch-eligible flags. Define the metric, the threshold and the window, then let the system flip the flag off without waiting for a human to wake up. Alert the human afterwards.
Contentful recommends logging flag logic (contentful.com/blog/what-are-feature-flags/). Logging is the weakest form of this. Logs tell you what happened if you know when to look. Metrics tell you something is wrong now.
14. Watch the total cost of payload, evaluations and vendor pricing
Payload. Targeting individual user IDs works at 200 users and collapses at 200,000, because those IDs live in the configuration shipped to every SDK instance (docs.getunleash.io). Group them into a segment and target the segment.
Memory. Every process holds the full config. A 4MB payload across 300 Kubernetes pods is 1.2GB of RAM describing booleans.
Pricing. Four axes, and which is cheapest depends entirely on your shape.
| Model | Cost driver | Scaling risk | Suits |
|---|---|---|---|
| Per seat | Engineers with dashboard access | Rises with headcount, not usage. Encourages credential sharing, which breaks your audit log. | Small teams, large user bases |
| Per monthly active user / context | Distinct users evaluated per month | Consumer scale gets expensive fast; anonymous users can each count as a context | B2B with bounded user counts |
| Per evaluation / event | Request volume | Puts a meter on your hot path; discourages the fine-grained flags rule 12 recommends | Low-traffic, high-headcount |
| Self-hosted open source | Your infrastructure and engineering time | Free until you need HA, upgrades and on-call for it | Teams with platform capacity |

Visual needed: Comparison built at build time from the published list pricing of LaunchDarkly, Split, Flagsmith, ConfigCat, Statsig, Unleash and GrowthBook, worked at two scales (20 engineers / 50k MAU, and 100 engineers / 5M MAU). Published list rates only, no quotes, and every cell stamped with the date the page was read.
The self-host escape hatch is under-discussed on vendor pages, for obvious reasons. Dorkly’s 304 HN points and Flipt’s 137 (Dorkly, Flipt) are evidence of appetite for not paying per-MAU to store booleans.
Treat every latency benchmark on a vendor page as marketing, including self-reported competitive comparisons.
Build vs buy. Etsy’s open-source feature library comes up in the Ask HN microservices thread as the build-your-own precedent (news.ycombinator.com/item?id=16619891), alongside Ruby’s Flipper, Java’s Togglz and django-waffle. Writing a toggle router is an afternoon. The cost is everything around it: a UI non-engineers will use, an audit log, SDK maintenance in every language you ship, and the availability engineering in rule 5. Build if flags are core to your product. Buy if they are infrastructure.
Feature flag failure modes seen in the wild
This is the article’s most-linkable asset, and it must be built by reading public issue trackers rather than from memory. Every row carries a URL, or it does not ship.

Visual needed / RESEARCH REQUIRED BEFORE PUBLISH: Failure-mode table with columns: failure, mechanism, the practice above that prevents it, source URL. Populate from open and recently-closed issues in the public GitHub repositories of Unleash, Flipt, GrowthBook, Flagsmith and OpenFeature, recording issue number, title, state, date opened and reaction count. Hunt five categories: (a) SDK serving stale or default values after a network partition; (b) streaming connections that silently stop delivering updates; (c) inconsistent bucketing between two SDK languages for the same user; (d) memory growth with large payloads; (e) permission or audit-log gaps. Drop any category that yields nothing verifiable rather than inventing a row.
Open-source trackers are visible; commercial ones are not. Every row will come from Unleash, Flipt, GrowthBook, Flagsmith or OpenFeature, because those are the projects whose bug reports are public. Absence of public issues for a closed-source provider is evidence of a private tracker, not of fewer bugs. The table systematically penalises vendors who let you see their defects, and a reader comparing vendors with it should know that.
Knight Capital. On 1 August 2012, Knight Capital Americas deployed new order-routing code to seven of its eight production servers. The deployment repurposed a flag that had previously activated Power Peg, a function retired in 2003 and never deleted from the codebase. On the eighth server, flipping that flag woke the dead code. In roughly 45 minutes Knight executed more than 4 million orders across 154 stocks and took a pre-tax loss of about $460 million; the SEC settled charges on 16 October 2013 with a $12 million penalty (sec.gov/litigation/admin/2013/34-70694.pdf). Fowler cites the episode as the cautionary tale of mismanaged toggles (martinfowler.com/articles/feature-toggles.html).
Three separate lapses stack there, and only one is a flag problem. The deploy reached 7 of 8 hosts, a release-process failure. The 2003 code was never removed, a cleanup failure. The flag name was reused for a new purpose, which is the failure rules 1 and 2 exist to prevent. Any one alone is survivable.
A feature flag standard you can paste into your engineering handbook
The feature flag best practices above, compressed into something a team can adopt today. Rules marked [HARD] should not be negotiated away; the rest are preferences with real tradeoffs.
FEATURE FLAG STANDARD | v1
NAMING
- Pattern: <team-or-domain>.<type>.<feature>-<ticket>
e.g. checkout.release.express-pay-PAY-1421
- Types: release | experiment | ops | permissioning | entitlement
- [HARD] Names are globally unique across all projects.
- [HARD] Retired names are never reused.
- [HARD] Flag keys are literal strings. No dynamic key construction, ever.
REQUIRED METADATA AT CREATION
- Owner (a person, not a team alias)
- Type (one of the five)
- Death condition (an observable event, or "never, reviewed <cadence>")
- Ticket that will remove it (created in the same commit as the flag)
DEFAULT VALUE POLICY (value served when flag data is unavailable)
- release -> false (old path)
- experiment -> control variant
- ops/kill switch-> the HEALTHY state. Name as disable-* defaulting false,
or enable-* defaulting true. [HARD] Never let loss of
flag data put the system into degraded mode.
- permissioning -> deny
- entitlement -> deny
CHANGE CONTROL
- Read access to flag state: everyone, all environments.
- Write access, non-production: any engineer.
- Write access, production: SSO group + second approver on the change.
- Break-glass: on-call may bypass approval; the bypass is logged and
reviewed at the next working day's standup.
- [HARD] Flag change events are piped into the deploy feed and the
incident timeline.
CLEANUP SLA
- release -> removed within 7 days of reaching 100% in production
- experiment -> removed within 7 days of the result being declared
- ops -> not removed; reviewed quarterly by the owning on-call
- permissioning -> reviewed when the plan or role model changes
- entitlement -> reviewed when the plan is retired
- Removing a flag removes: the flag, the losing code path, and the flag's
test parameterisation. All three in one PR.
MONITORING (every flag emits)
- evaluation count by variant
- last-evaluated timestamp
- error rate segmented by variant
- age of last successful SDK sync (per process, alert at 3x sync interval)
ARCHITECTURE
- [HARD] No hard runtime dependency on the flag provider. Bootstrap file
+ in-memory cache + local evaluation.
- [HARD] No raw flag configuration is served to a browser. Frontends
receive evaluated results only.
- Flags expected to outlive one release cycle use a decision layer with
intention-revealing method names (team-negotiable for shorter flags).
- New integrations bind to the OpenFeature API, not a vendor SDK.
CREATION CHECKLIST (5 lines)
[ ] Type chosen and encoded in the name
[ ] Owner named
[ ] Death condition recorded; removal ticket created
[ ] Default value set per the policy above
[ ] Test parameterisation added for both states
REMOVAL CHECKLIST (3 lines)
[ ] Losing code path deleted, not just the conditional
[ ] Test parameterisation deleted
[ ] Flag archived (not deleted); name permanently retired

Visual needed: The 5-line creation checklist and 3-line removal checklist as two printable cards, with the five [HARD] rules down the side. Content from the block above.
The default-value policy and the entitlement handling are this author’s position, synthesised from the sources above rather than drawn from an established standard. Adapt the SLA numbers to your release cadence and keep the [HARD] rules as written.
Frequently Asked Questions
What is the difference between a feature toggle and a feature flag?
They are the same thing. Feature toggle, feature flag, feature switch, feature bit and feature flipper all name one mechanism, and Fowler’s 2017 article uses toggle and flag interchangeably. One soft convention: “toggle” implies a boolean, while “flag” gets used loosely for multivariate and JSON-valued config too. No technical distinction exists.
How long should a feature flag live?
It depends on the type. Fowler puts release toggles at a week or two and experiment toggles at however long significance takes, hours to weeks. Ops and permissioning toggles are indefinite by design, and premium gating can run for years. A recorded death condition and a named owner matter more than the lifetime.
How many feature flags is too many?
Raw count is the wrong metric. Three signals matter: how many flags have passed their death condition, how many have no living owner, and how many share a function with another flag. Most feature flag best practices content says there is no upper limit provided stale flags get removed. The practical ceiling is flags interacting in one code path.
Should feature flags be evaluated on the client or the server?
Server-side by default. Client-side evaluation ships targeting rules, segment definitions and unreleased feature names to anyone with devtools open, and sends user PII to your provider. For frontends, call a server-side endpoint that returns only results, and embed the initial flag set in the page to avoid a flash of the wrong variant.
How do you test code that is behind a feature flag?
Ten flags is 1,024 combinations, so exhaustive testing is out. Test two: the configuration production runs now, and the one it runs after the release. Parameterise the suite by injecting a fake toggle router, use signed per-request overrides so QA can exercise variants in shared environments, and treat two flags in the same function as one test dimension.
How do you implement feature flags in Python?
Put a decision object between your code and the flag source, expose intention-revealing methods, and inject it:
from typing import Protocol
class ToggleSource(Protocol):
def is_enabled(self, key: str, ctx: dict) -> bool: ...
class DictToggles: # test double
def __init__(self, state): self.state = state
def is_enabled(self, key, ctx): return self.state.get(key, False)
class FeatureDecisions:
def __init__(self, toggles: ToggleSource): self._t = toggles
def use_express_pay(self, user) -> bool:
return self._t.is_enabled(
"checkout.release.express-pay-PAY-1421",
{"user_id": user.id, "plan": user.plan},
)
# production wiring uses an SDK-backed ToggleSource;
# tests pass DictToggles({"checkout.release.express-pay-PAY-1421": True})
The OpenFeature Python SDK is the vendor-neutral implementation of ToggleSource, which keeps provider choice out of application code. django-waffle is the batteries-included alternative if you are already on Django and want flags in the ORM.
What happens if the feature flag service goes down?
A correct integration keeps serving from its in-memory cache and never blocks a request on the provider. Three requirements: bootstrap values available before the first sync, a per-flag default chosen deliberately (kill switches usually need inverted polarity so an outage does not degrade the product), and an alert on the age of the last successful sync.
Can you use feature flags for pricing tiers and entitlements?
You can, and Fowler’s permissioning toggle covers the case, but the risks are specific. Flag systems are eventually consistent and fail into a default, which for entitlements can mean giving paid features away. Flag audit logs are not billing records. Per-MAU or per-evaluation pricing makes every authenticated request cost money. Keep billing as the source of truth; the open Ask HN thread shows the field has not settled this.
What are the best feature flag tools?
Split them by architecture rather than ranking them. Managed SaaS: LaunchDarkly, Split, Statsig, ConfigCat, Harness, PostHog, Optimizely, DevCycle. Cloud provider config services: AWS AppConfig, Azure App Configuration, Firebase Remote Config for mobile. Open-source self-hosted: Unleash, Flipt, Flagsmith, GrowthBook. GitOps and declarative: Flipt’s Git and OCI backends, Dorkly’s YAML-in-GitHub. Across all of them, OpenFeature is a CNCF project, in the foundation since 2022, defining a vendor-neutral spec that turns switching into a wiring change.
Most best-practice content on this topic, including several pages ranking for this query, is published by flag vendors whose recommendations map to their product capabilities. Read it with that in mind, this article included.
Two questions this article cannot answer from desk research
How does each SDK behave under a network partition? How long does it serve cached values? What does it do on cold start with no cache file: block, throw, or serve defaults? Does it distinguish “flag not found” from “provider unreachable”? Version-specific and vendor-specific. Run your service with the provider’s hostname blackholed in /etc/hosts, restart a process, and record what the first 1,000 evaluations return.
Do two SDKs in different languages bucket the same user identically? If your Go backend and your JavaScript frontend both evaluate the same 50% rollout for user u_8813, do they agree? A mismatch produces a user seeing the new UI calling the old API. Push a fixed list of 10,000 identifiers through both SDKs against one configuration and diff the output.
Do that before you ship a rollout spanning both. Then work back through the 14 feature flag best practices above and check the two that hold up everything else: every flag has a type, and every flag has a death condition with a name attached to it.
Frequently Asked Questions
What is the difference between a feature toggle and a feature flag?
They are the same thing. Feature toggle, feature flag, feature switch, feature bit and feature flipper all name one mechanism, and Fowler's 2017 article uses toggle and flag interchangeably. One soft convention: "toggle" implies a boolean, while "flag" gets used loosely for multivariate and JSON-valued config too. No technical distinction exists.
How long should a feature flag live?
It depends on the type. Fowler puts release toggles at a week or two and experiment toggles at however long significance takes, hours to weeks. Ops and permissioning toggles are indefinite by design, and premium gating can run for years. A recorded death condition and a named owner matter more than the lifetime.
How many feature flags is too many?
Raw count is the wrong metric. Three signals matter: how many flags have passed their death condition, how many have no living owner, and how many share a function with another flag. Most feature flag best practices content says there is no upper limit provided stale flags get removed. The practical ceiling is flags *interacting in one code path*.
Should feature flags be evaluated on the client or the server?
Server-side by default. Client-side evaluation ships targeting rules, segment definitions and unreleased feature names to anyone with devtools open, and sends user PII to your provider. For frontends, call a server-side endpoint that returns only results, and embed the initial flag set in the page to avoid a flash of the wrong variant.
How do you test code that is behind a feature flag?
Ten flags is 1,024 combinations, so exhaustive testing is out. Test two: the configuration production runs now, and the one it runs after the release. Parameterise the suite by injecting a fake toggle router, use signed per-request overrides so QA can exercise variants in shared environments, and treat two flags in the same function as one test dimension.
How do you implement feature flags in Python?
Put a decision object between your code and the flag source, expose intention-revealing methods, and inject it: ```python from typing import Protocol class ToggleSource(Protocol): def is_enabled(self, key: str, ctx: dict) -> bool: ... class DictToggles: # test double def __init__(self, state): self.state = state def is_enabled(self, key, ctx): return self.state.get(key, False) class FeatureDecisions: def __init__(self, toggles: ToggleSource): self._t = toggles def use_express_pay(self, user) -> bool: return self._t.is_enabled( "checkout.release.express-pay-PAY-1421", {"user_id": user.id, "plan": user.plan}, ) ``` The OpenFeature Python SDK is the vendor-neutral implementation of `ToggleSource`, which keeps provider choice out of application code. django-waffle is the batteries-included alternative if you are already on Django and want flags in the ORM.
What happens if the feature flag service goes down?
A correct integration keeps serving from its in-memory cache and never blocks a request on the provider. Three requirements: bootstrap values available before the first sync, a per-flag default chosen deliberately (kill switches usually need inverted polarity so an outage does not degrade the product), and an alert on the age of the last successful sync.
Can you use feature flags for pricing tiers and entitlements?
You can, and Fowler's permissioning toggle covers the case, but the risks are specific. Flag systems are eventually consistent and fail into a default, which for entitlements can mean giving paid features away. Flag audit logs are not billing records. Per-MAU or per-evaluation pricing makes every authenticated request cost money. Keep billing as the source of truth; the [open Ask HN thread](https://news.ycombinator.com/item?id=38907509) shows the field has not settled this.
Explore More
Related Articles
- The 4 Best A/B Testing Tools in 2026, Ranked by Stats Engine and Real Cost
- The Best A/B Testing Tools for Startups in 2026 (Real Stats, Startup Budgets)
- The Cheapest Feature Flag Tools in 2026, Ranked by How You Actually Save
- The 4 Best Experimentation Platforms in 2026, Ranked by Who Should Actually Buy Them
- The Best Feature Flag Tools for B2B SaaS in 2026 (Entitlements, Not Just Toggles)
Free Newsletter
Get the Feature Flags Newsletter
Platform benchmarks, real pricing data and progressive delivery practice. No spam.
Related Articles
The 4 Best A/B Testing Tools in 2026, Ranked by Stats Engine and Real Cost
Most "A/B testing" is a percentage rollout with a chart bolted on. These four run real statistics. Here are the best A/B testing tools ranked on engine depth, data model and price, with each one's catch.
July 26, 2026
best-ofThe Best A/B Testing Tools for Startups in 2026 (Real Stats, Startup Budgets)
Startups need real experimentation without a real experimentation budget. Here are three tools with genuine free tiers and rigorous stats engines, matched to how much data infrastructure you already have.
July 26, 2026
best-ofThe Cheapest Feature Flag Tools in 2026, Ranked by How You Actually Save
Cheap means different things - free forever, flat and predictable, or free to self-host. Here are three feature flag tools that each get you cheap a different way, with the honest catch on each.
July 26, 2026