DEV Community

Cover image for Feature Flags: Enabling and Disabling Features Without Redeploying
Rhuturaj Takle
Rhuturaj Takle

Posted on

Feature Flags: Enabling and Disabling Features Without Redeploying

Feature Flags: Enabling and Disabling Features Without Redeploying

A practical guide to feature flags — the technique for toggling application behavior at runtime without a new deployment, covering flag types, implementation in .NET, targeting and gradual rollouts, flag lifecycle management, testing implications, and how flags complement the deployment strategies covered elsewhere in this series.


Table of Contents

  1. Introduction
  2. Why Feature Flags Exist
  3. Types of Feature Flags
  4. Implementing Feature Flags in .NET
  5. Targeting and Gradual Rollouts
  6. Feature Flags as Kill Switches
  7. A/B Testing and Experimentation
  8. Flag Management Services
  9. Flag Lifecycle: The Part Everyone Forgets
  10. Testing Implications
  11. Performance and Reliability Considerations
  12. Common Pitfalls
  13. Quick Reference Table
  14. Conclusion

Introduction

A feature flag (also called a feature toggle) is a conditional check in application code that determines whether a piece of functionality is active, controlled by configuration that can change at runtime — without rebuilding, redeploying, or restarting the application. The core idea, touched on briefly in this series' CI/CD Pipelines guide, is a genuinely powerful decoupling: deploying new code and releasing a new feature become two separate, independently controllable events.

if (await _featureManager.IsEnabledAsync("NewCheckoutFlow", user))
{
    return await NewCheckoutFlowAsync(order);
}
return await LegacyCheckoutFlowAsync(order);
Enter fullscreen mode Exit fullscreen mode

That new checkout flow can sit deployed in production, completely dormant, for days before anyone sees it — then be switched on instantly for 1% of users, then 10%, then everyone, all without another deployment, and switched back off just as instantly if something looks wrong.


1. Why Feature Flags Exist

Decoupling deployment from release

Without feature flags, "deploy" and "release" are the same event — the moment new code reaches production, every user is exposed to it simultaneously. This conflates two genuinely different risks: is this deployment mechanically successful (did the code run without errors, are health checks passing) and is this feature actually good (do users like it, does it convert, does it perform well under real load) — with feature flags, these become separable questions, each answerable and reversible independently.

Reducing the blast radius of a bad release

if (await _featureManager.IsEnabledAsync("NewRecommendationEngine"))
{
    return await _newRecommendationEngine.GetRecommendationsAsync(userId);
}
return await _legacyRecommendationEngine.GetRecommendationsAsync(userId);
Enter fullscreen mode Exit fullscreen mode

If the new recommendation engine has a subtle bug that only manifests under real production load, a feature flag lets you disable it for everyone in seconds — no rollback deployment, no waiting for a new build to go through the pipeline, just a configuration change that takes effect immediately. This is meaningfully faster than even the fastest rollback-via-redeployment strategy covered in this series' CI/CD Pipelines guide.

Enabling trunk-based development

main branch: always deployable, always releasable
  ├── feature work committed directly to main, behind a flag, in small increments
  └── no long-lived feature branches accumulating weeks of divergent changes
Enter fullscreen mode Exit fullscreen mode

Feature flags are what make trunk-based development — everyone committing frequently to a single shared branch, rather than working in long-lived feature branches — practical for genuinely large or risky features. Instead of a feature branch that diverges from main for weeks and merges as one enormous, high-risk change, the feature is built incrementally, directly on main, hidden behind a flag until it's actually ready, keeping the continuous integration benefits described in this series' CI/CD Pipelines guide intact even for substantial, multi-week features.

Progressive delivery and experimentation

As covered in the CI/CD Pipelines guide's canary deployment section, flags enable fine-grained, per-user or per-segment control over who sees what — the foundation both for cautious progressive rollouts and for genuine A/B experimentation (Section 6).


2. Types of Feature Flags

Not all feature flags serve the same purpose, and conflating them leads to poor lifecycle management (Section 8) — it's worth being explicit about which category a given flag belongs to.

Release flags

if (await _featureManager.IsEnabledAsync("NewCheckoutFlow"))
Enter fullscreen mode Exit fullscreen mode

The most common type — wrapping a specific new feature so it can be released gradually or instantly rolled back. Short-lived by design: once the feature is fully rolled out and confirmed stable, the flag (and the old code path it replaced) should be removed.

Experiment flags (A/B testing)

var variant = await _featureManager.GetVariantAsync("CheckoutButtonColor", user);
Enter fullscreen mode Exit fullscreen mode

Used to run a controlled experiment — different users see different variants, and the flag's purpose is measuring which performs better against a specific metric, not simply rolling out a feature. Also generally short- to medium-lived: once the experiment concludes and a winning variant is chosen, the flag should be resolved to that outcome and removed.

Ops/kill-switch flags

if (await _featureManager.IsEnabledAsync("EnableThirdPartyRecommendations"))
Enter fullscreen mode Exit fullscreen mode

Used to disable a specific piece of functionality (often one with an external dependency) quickly during an incident, without needing a code change or redeploy. Unlike release flags, these are often intentionally long-lived — a permanent safety valve around a genuinely risky or fragile dependency, not something meant to be cleaned up once "done."

Permission/entitlement flags

if (await _featureManager.IsEnabledAsync("AdvancedReporting", user))
Enter fullscreen mode Exit fullscreen mode

Used to gate functionality based on a user's plan tier, role, or entitlement (an "Enterprise-only" feature, say) — these are also intentionally long-lived, since they represent an ongoing business rule rather than a temporary rollout mechanism, and are arguably closer to authorization logic than to a "feature flag" in the rollout sense, even though they're often implemented with the same underlying toggle infrastructure.

Why the distinction matters

Treating every flag as if it's a short-lived release flag (Section 8) leads to appropriate cleanup discipline for release flags but risks accidentally removing a long-lived ops or entitlement flag that was never meant to go away — being explicit about a flag's category at creation time (often via a naming convention or metadata tag) helps avoid this confusion later.


3. Implementing Feature Flags in .NET

Microsoft.FeatureManagement

builder.Services.AddFeatureManagement();
Enter fullscreen mode Exit fullscreen mode
// appsettings.json
{
  "FeatureManagement": {
    "NewCheckoutFlow": true,
    "NewRecommendationEngine": false
  }
}
Enter fullscreen mode Exit fullscreen mode
public class CheckoutService
{
    private readonly IFeatureManager _featureManager;
    public CheckoutService(IFeatureManager featureManager) => _featureManager = featureManager;

    public async Task<Order> ProcessCheckoutAsync(Cart cart)
    {
        if (await _featureManager.IsEnabledAsync("NewCheckoutFlow"))
        {
            return await NewCheckoutFlowAsync(cart);
        }
        return await LegacyCheckoutFlowAsync(cart);
    }
}
Enter fullscreen mode Exit fullscreen mode

Microsoft.FeatureManagement is the standard .NET library for feature flags, integrating directly with the configuration system covered in this series' ASP.NET Core guide — flags can be sourced from appsettings.json, environment variables, or (more powerfully, for runtime changes without a redeploy at all) Azure App Configuration (Section 7).

Feature-gated MVC actions and middleware

[FeatureGate("NewCheckoutFlow")]
public class NewCheckoutController : ControllerBase
{
    // this entire controller only routes if the flag is enabled
}
Enter fullscreen mode Exit fullscreen mode
app.UseMiddleware<FeatureFlagMiddleware>("BetaFeatures");
Enter fullscreen mode Exit fullscreen mode

Microsoft.FeatureManagement.AspNetCore extends the core library with MVC/Razor Pages-aware constructs — gating an entire controller/action or a middleware component behind a flag declaratively, rather than an explicit if check scattered through business logic.

Feature-gated UI in Razor

<feature name="NewCheckoutFlow">
    <partial name="_NewCheckoutForm" />
</feature>
<feature name="NewCheckoutFlow" negate="true">
    <partial name="_LegacyCheckoutForm" />
</feature>
Enter fullscreen mode Exit fullscreen mode

A <feature> tag helper lets Razor views conditionally render markup based on a flag's state, keeping the same pattern consistent from backend logic through to the UI layer.

Feature filters: conditional logic beyond simple on/off

builder.Services.AddFeatureManagement()
    .AddFeatureFilter<PercentageFilter>()
    .AddFeatureFilter<TimeWindowFilter>();
Enter fullscreen mode Exit fullscreen mode
{
  "FeatureManagement": {
    "NewCheckoutFlow": {
      "EnabledFor": [
        { "Name": "Percentage", "Parameters": { "Value": 25 } }
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Feature filters let a flag's enabled state depend on more than a static boolean — the built-in PercentageFilter enables a flag for a specified percentage of evaluations (the basis for gradual rollouts, Section 4), and TimeWindowFilter enables a flag only during a specific date/time range (useful for a scheduled feature launch or a time-boxed promotion).


4. Targeting and Gradual Rollouts

Percentage-based rollout

{
  "FeatureManagement": {
    "NewRecommendationEngine": {
      "EnabledFor": [
        { "Name": "Percentage", "Parameters": { "Value": 10 } }
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Enabling a flag for 10% of evaluations, then increasing that percentage over hours or days while watching error rates and business metrics, is the flag-based equivalent of the canary deployment strategy covered in this series' CI/CD Pipelines and Kubernetes/Helm guides — except the "canary" here is a code path within a single deployed version, not a separate deployed instance, making the rollout finer-grained and reversible in an instant.

Targeting specific users or groups

public class TargetingContextAccessor : ITargetingContextAccessor
{
    public ValueTask<TargetingContext> GetContextAsync() =>
        new(new TargetingContext
        {
            UserId = _httpContextAccessor.HttpContext?.User?.Identity?.Name,
            Groups = new[] { "BetaTesters" }
        });
}
Enter fullscreen mode Exit fullscreen mode
{
  "FeatureManagement": {
    "NewCheckoutFlow": {
      "EnabledFor": [
        {
          "Name": "Microsoft.Targeting",
          "Parameters": {
            "Audience": {
              "Users": ["ada@example.com"],
              "Groups": [{ "Name": "BetaTesters", "RolloutPercentage": 100 }],
              "DefaultRolloutPercentage": 5
            }
          }
        }
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The targeting filter enables a flag based on specific user identities, named groups, or a percentage rollout applied consistently per user (so the same user always gets the same experience across requests, rather than a coin flip on every page load) — this consistency matters enormously for user experience coherence and for producing trustworthy experiment data (Section 6).

Consistent hashing: the mechanism behind "sticky" percentage rollouts

hash(userId + flagName) % 100 < rolloutPercentage
Enter fullscreen mode Exit fullscreen mode

A well-implemented percentage rollout doesn't use a random coin flip on every evaluation — it deterministically hashes a stable identifier (the user ID, combined with the flag name) into a consistent bucket, so a given user reliably sees the same variant across multiple requests and sessions, only shifting into or out of the rollout as the overall percentage threshold changes. This is what makes a gradual rollout coherent rather than confusingly flickering between old and new behavior for the same user from one request to the next.


5. Feature Flags as Kill Switches

The fastest possible mitigation for a bad feature

if (await _featureManager.IsEnabledAsync("EnableThirdPartyPaymentProvider"))
{
    return await _thirdPartyPaymentProvider.ChargeAsync(order);
}
return await _fallbackPaymentProvider.ChargeAsync(order);
Enter fullscreen mode Exit fullscreen mode

If a third-party dependency starts failing or behaving unexpectedly in production, flipping a kill-switch flag to fall back to a degraded-but-functional path is dramatically faster than any redeployment-based mitigation — this is often the single fastest lever available during a production incident, faster even than the rollback strategies covered in this series' CI/CD Pipelines guide, since no build, no deployment pipeline, and no container restart is involved at all.

Designing for graceful degradation, not just on/off

if (await _featureManager.IsEnabledAsync("EnablePersonalizedRecommendations"))
{
    return await _personalizationService.GetRecommendationsAsync(userId);
}
return await _staticFallbackRecommendationsAsync(); // a reasonable, if less personalized, default
Enter fullscreen mode Exit fullscreen mode

The most valuable kill switches aren't "feature on, or the whole page breaks" — they're wired to a genuinely reasonable fallback, so disabling a struggling dependency degrades the experience gracefully (showing generic recommendations instead of personalized ones) rather than taking down an entire page or workflow. Identifying which dependencies deserve this kind of flag-guarded fallback — usually anything external, rate-limited, or historically unreliable — is worth doing proactively, before an incident forces the question.

Practicing the kill switch before you need it

A kill-switch flag that's never actually been toggled in production is an untested piece of your incident response plan — periodically exercising it deliberately (as part of a planned game day, or simply toggling it briefly during a low-traffic period) confirms both that the flag mechanism itself works and that the fallback path is actually functional, not just theoretically present in the code.


6. A/B Testing and Experimentation

Beyond simple on/off: variants

var variant = await _featureManager.GetVariantAsync("CheckoutButtonColor", targetingContext);
var buttonColor = variant?.Configuration?.Value<string>() ?? "blue"; // default
Enter fullscreen mode Exit fullscreen mode
{
  "FeatureManagement": {
    "CheckoutButtonColor": {
      "Variants": [
        { "Name": "Blue", "ConfigurationValue": "blue" },
        { "Name": "Green", "ConfigurationValue": "green" }
      ],
      "Allocation": {
        "DefaultWhenDisabled": "Blue",
        "User": [],
        "Group": [],
        "Percentile": [
          { "Variant": "Blue", "From": 0, "To": 50 },
          { "Variant": "Green", "From": 50, "To": 100 }
        ]
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Where a simple flag is binary (on/off), a variant allows more than two possible experiences to be assigned across an experiment's participants — the foundation for genuine A/B (or A/B/n) testing, where each variant's business impact (conversion rate, engagement, revenue) is measured and compared.

The line between a feature flag and a full experimentation platform

Basic percentage/variant allocation (as shown above) handles the targeting half of an experiment — deciding who sees what. A genuine A/B test also needs the measurement half: tracking which variant each user was exposed to, correlating that with the outcome metric being measured, and running statistical significance analysis on the results. Feature flag libraries and services increasingly bundle this analytics layer (Section 7), but it's worth recognizing that "can toggle a variant" and "can run a statistically rigorous experiment" are related but distinct capabilities — the latter needs deliberate experiment design (sample size, success metrics defined upfront) regardless of how sophisticated the flagging mechanism itself is.


7. Flag Management Services

Azure App Configuration

builder.Configuration.AddAzureAppConfiguration(options =>
{
    options.Connect(connectionString)
        .UseFeatureFlags(featureFlagOptions =>
        {
            featureFlagOptions.CacheExpirationInterval = TimeSpan.FromSeconds(30);
        });
});
Enter fullscreen mode Exit fullscreen mode

Azure App Configuration extends Microsoft.FeatureManagement with centralized, runtime-updatable flag storage — flags can be toggled from the Azure Portal (or via IaC, as covered in this series' Terraform/Bicep guide) and take effect across every running application instance within the configured cache refresh interval, without any redeployment or restart at all, which is the fullest realization of the "no redeploy needed" promise feature flags make.

Third-party flag management platforms

Dedicated commercial platforms (LaunchDarkly, Split, Flagsmith, and others) build on the same core concepts with additional capabilities: richer targeting rule UIs for non-engineers to manage, built-in experiment analytics and statistical significance testing, audit logs of every flag change, and SDKs across many languages beyond just .NET for organizations with polyglot codebases.

Build vs. buy

Configuration-based (appsettings/environment variables) Azure App Configuration Dedicated platform (LaunchDarkly, etc.)
Requires a redeploy to change Yes No No
Centralized across services/instances No (unless paired with shared config) Yes Yes
Targeting rules (percentage, user, group) Manual implementation Built-in via feature filters Rich, UI-driven
Experimentation/analytics Not included Not included Often included
Non-engineer flag management Not practical Possible via Portal Designed for this
Cost Free Low (Azure service cost) Ongoing subscription

For a small application or an early-stage project, simple configuration-based flags (requiring a redeploy to change, but still decoupling the decision from the feature branch) may be entirely sufficient — reaching for a dedicated platform earns its cost once an organization genuinely needs non-engineers managing rollouts, rich experimentation analytics, or flag consistency across many independently deployed services.


8. Flag Lifecycle: The Part Everyone Forgets

Flags accumulate, and that's a real cost

// Six months later, nobody remembers why these are still here, or which is safe to remove
if (await _featureManager.IsEnabledAsync("NewCheckoutFlow")) { /* ... */ }
if (await _featureManager.IsEnabledAsync("NewCheckoutFlowV2")) { /* ... */ }
if (await _featureManager.IsEnabledAsync("CheckoutExperimentQ2")) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

Every flag left in the codebase after its rollout is complete adds a permanent branch of conditional logic, a permanent pair of code paths to test and maintain, and genuine cognitive load for anyone reading that code later trying to understand what's actually active. Left unmanaged, flag debt accumulates the same way any other form of technical debt does — quietly, and increasingly expensive to untangle the longer it's ignored.

Treat "remove the flag" as part of the feature's definition of done

Feature: New checkout flow
  ✓ Implemented behind a flag
  ✓ Rolled out to 100% of users
  ✓ Confirmed stable for 2 weeks
  ☐ Flag and legacy code path removed  ← this step is often skipped
Enter fullscreen mode Exit fullscreen mode

The cleanup step — removing the flag check and the now-dead legacy code path once a release flag has been fully and stably rolled out — is as much a part of "shipping the feature" as the initial implementation, but it's the step most commonly skipped, since there's no urgent pressure driving it the way there was pressure to ship the feature itself in the first place.

Auditing stale flags

grep -r "IsEnabledAsync" --include="*.cs" . | grep -oP '"\K[^"]+(?=")' | sort -u
Enter fullscreen mode Exit fullscreen mode

Periodically auditing which flags exist in code against which flags are actually still varying in production configuration (a flag permanently at 100% for months, with no experiment or ops purpose, is a strong candidate for removal) is worth building into a team's regular technical-debt/cleanup cadence — the same kind of recurring hygiene discipline covered in this series' Cloud Cost Optimization guide for unused cloud resources, applied here to unused code branches instead.

Distinguishing what should and shouldn't be cleaned up

As covered in Section 2, only release and experiment flags are generally expected to be short-lived and cleaned up — ops/kill-switch and permission/entitlement flags are intentionally long-lived, and a flag-cleanup effort should be careful to distinguish between "this flag has served its purpose and should go" and "this flag is a permanent, deliberate part of the system's design."


9. Testing Implications

Testing both sides of a flag

[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Checkout_WorksCorrectly_RegardlessOfFlagState(bool flagEnabled)
{
    var featureManager = new FakeFeatureManager(("NewCheckoutFlow", flagEnabled));
    var service = new CheckoutService(featureManager);

    var result = await service.ProcessCheckoutAsync(TestCart());

    Assert.NotNull(result);
}
Enter fullscreen mode Exit fullscreen mode

While both the old and new code paths coexist behind a flag, both need to be tested — a test suite that only ever exercises the "flag enabled" path (because that's what's active in the developer's local environment) risks silently letting the "flag disabled" path bit-rot, which matters because that's exactly the path a kill switch would fall back to during an incident, and it needs to actually still work when that moment arrives.

The combinatorial explosion risk

2 flags → 4 possible combinations
5 flags → 32 possible combinations
10 flags → 1,024 possible combinations
Enter fullscreen mode Exit fullscreen mode

Multiple simultaneously-active flags interacting with each other creates a combinatorial explosion of possible application states, only a fraction of which are realistically tested — this is a genuine, underappreciated argument for flag lifecycle discipline (Section 8): every flag left in the codebase past its useful life doesn't just add cognitive overhead, it multiplies the theoretical state space the application can be in, most of which nobody has actually verified works correctly.

Feature flags in automated pipeline testing

- name: Run tests with new checkout flow enabled
  env:
    FeatureManagement__NewCheckoutFlow: "true"
  run: dotnet test

- name: Run tests with new checkout flow disabled
  env:
    FeatureManagement__NewCheckoutFlow: "false"
  run: dotnet test
Enter fullscreen mode Exit fullscreen mode

For a flag guarding genuinely significant, business-critical functionality, explicitly running the test suite (or at least the relevant subset) with the flag in both states as part of CI — rather than relying on whatever the default happens to be — closes the gap described above for at least the most important flags, connecting directly to the layered testing strategy covered in this series' CI/CD Pipelines guide.


10. Performance and Reliability Considerations

Flag evaluation should be fast and local where possible

// Cached/local evaluation — fast, no network call per request
if (await _featureManager.IsEnabledAsync("NewCheckoutFlow"))
Enter fullscreen mode Exit fullscreen mode

A feature flag check happens on a hot path — potentially every single request — so the evaluation itself needs to be fast, typically via a locally cached copy of flag configuration (refreshed periodically in the background) rather than a network round trip to a flag service on every single check. Azure App Configuration's CacheExpirationInterval setting (Section 7) is exactly this trade-off: a longer interval means flag changes take longer to propagate but reduces load and latency; a shorter interval propagates changes faster at the cost of more frequent refresh calls.

What happens if the flag service is unreachable?

builder.Configuration.AddAzureAppConfiguration(options =>
{
    options.Connect(connectionString)
        .UseFeatureFlags();
}, optional: true); // fall back to local cached/default values if the service is unreachable
Enter fullscreen mode Exit fullscreen mode

A flag management service becoming unreachable shouldn't take down the application depending on it — a well-designed flag client falls back to its last-known-good cached configuration (or a sensible built-in default) rather than failing the request or throwing an exception, since the flag service itself is now one more dependency in the critical path, and it needs its own graceful-degradation story just like any other external dependency.


11. Common Pitfalls

Pitfall Why it hurts Better approach
Never removing flags after full rollout Flag debt accumulates, combinatorial test state space grows unmanaged Treat flag removal as part of the feature's definition of done
Conflating release flags with permanent ops/entitlement flags Risk of accidentally deleting a flag that was meant to be permanent Categorize flags explicitly (Section 2) at creation time
Only testing the "flag enabled" code path The disabled/fallback path may silently break, exactly when a kill switch needs it most Test both states, especially for business-critical flags
Random (non-sticky) percentage rollout Users flicker between old/new behavior across requests, confusing UX and unreliable experiment data Use consistent, identity-based hashing so a given user's experience stays stable
Flag service outage taking down the whole application The flag check becomes a single point of failure for everything it gates Cache flag state locally, fail open to a sensible default if the service is unreachable
Business-critical logic hidden entirely behind an undocumented flag Hard for anyone besides the original author to understand current production behavior Document what each flag does and its current rollout state, not just its existence in code
Using feature flags as a substitute for proper configuration/secrets management Conflates two different concerns with different security/audit needs Keep flags (behavioral toggles) distinct from configuration values and especially from secrets

Quick Reference Table

Concept Purpose
Release flag Short-lived toggle for rolling out a specific new feature
Experiment flag / variant Assigns different users to different experiences for A/B testing
Ops/kill-switch flag Long-lived safety valve for disabling risky functionality during an incident
Entitlement flag Long-lived gate based on plan tier, role, or business rule
Percentage filter Enables a flag for a defined percentage of evaluations
Targeting filter Enables a flag for specific users/groups, consistently per identity
Microsoft.FeatureManagement Standard .NET library for flag evaluation and ASP.NET Core integration
Azure App Configuration Centralized, runtime-updatable flag storage without redeployment
Consistent hashing Ensures a given user sees the same variant reliably across requests
Flag lifecycle/cleanup Removing a release flag and its dead code path once rollout is complete and stable

Conclusion

Feature flags solve a problem that deployment mechanics alone can't: separating "is this code running correctly in production" from "should users actually be experiencing this feature right now." That separation is what enables trunk-based development on large features, instant kill switches during incidents, gradual and reversible rollouts finer-grained than any deployment strategy alone can offer, and genuine A/B experimentation — all without touching the CI/CD pipeline itself.

The discipline that makes flags valuable rather than a growing liability is almost entirely about lifecycle: being deliberate about which category a flag belongs to, testing both sides of every meaningful flag, and — the step most teams skip — actually removing release flags and their dead code paths once a rollout is complete and stable. Get that discipline right, and feature flags become one of the most effective risk-reduction tools available; neglect it, and they become exactly the kind of quietly accumulating complexity this series has warned against in cloud resources, database migrations, and CI/CD pipelines alike.


Found this useful? Feel free to star the repo, open an issue with corrections, or share the kill switch that saved a production incident from becoming a much worse one.

Top comments (0)