Feature Flags Done Right: A Way Back and a Way Out

By Sergey Nosov

16 October 2026

Suppose a new pricing engine goes out on Tuesday, and on Thursday it turns out to be wrong for one customer in fifty. Or a new report, harmless in testing, takes the database down at nine in the morning. Either way, the question is how fast you can get back to what worked.

Publilius Syrus, a freed slave who wrote mimes for the Roman stage in Julius Caesar’s time, put it plainly: “It is a bad plan that admits of no modification.” A release that only another release can undo admits of very little. Undoing a deployment is a pipeline run: minutes on a good day, a change window and an approval on a bad one. A release behind a feature flag comes back with a switch.

A flag gives a release a way back, and every flag needs a way out of the code. Flags have come up on this site before. My article on deleting dead code tells how a reused flag woke nine-year-old code at Knight Capital, and the one on graceful degradation uses flags as kill switches. This article is about using them well: four kinds of flags, one decision point, sticky rollouts, tested kill switches, a record of every flip, and a date on every flag.

The examples are C# on .NET 10 with Microsoft.FeatureManagement 4.8.0 and the Azure App Configuration provider 8.6.0. I compiled the samples, ran their tests, and measured the provider’s behavior against Microsoft’s App Configuration emulator, a local implementation of the service. The practices carry over to any flag system.

Deployment Is Not Release

A deployment is a technical event; a release is a business decision. Deployment puts code into production; release makes it available for use. ITIL treats the two as separate practices, as my article on ITIL for developers describes. With nothing in between, they happen at the same moment, and the pipeline makes the business decision. Blue-green and canary deployments put a router in between for a whole version; a feature flag puts a switch in the code for a single feature. A release flag takes a feature through five steps:

  1. Merge to the main branch behind the flag, with the flag off.
  2. Deploy to production with the flag still off. Pete Hodgson calls code shipped this way “latent code”: it is in production, but nothing reaches it.
  3. Turn it on for the team. Hodgson calls a release to internal users first a “Champagne Brunch.”
  4. Roll out in rings, from a named group to a small share of users and up, with a look at the dashboards between steps.
  5. Turn it on for everyone, then delete the flag. The deletion is the step everybody skips.

Before step three, a pricing engine deserves a dark launch. Martin Fowler defines dark launching as calling new or changed back-end behavior “from existing users without the users being able to tell it’s being called.” Behind an off flag, the new engine never runs. Launched dark, it prices real orders in the background while customers still get the old price, and a log shows where the engines differ. The shadow path must not set off anything real, such as a charge or an email, or slow the live path.

Separating deployment from release changes two things. First, the way back shrinks. Undoing a release behind a flag needs no build and no pipeline run; each running instance picks up the change at its next configuration refresh. Second, unfinished work can merge. The alternative is a long-lived branch that drifts from main every day until one large, risky merge.

A flag also has a limit. Turning it off stops the new code path; it does not undo what that path already did. Quotes the new engine priced stay priced, rows it wrote stay written, and messages it sent stay sent. If the new path writes data, plan the data’s way back separately; my article on the Recoverable Change Principle covers that side.

Four Kinds of Flags, Managed Four Ways

A widely used taxonomy comes from Hodgson’s article “Feature Toggles (aka Feature Flags),” published on Martin Fowler’s site in 2016. He sorts flags along two dimensions: “how long the feature toggle will live and how dynamic the toggling decision must be.” Who manages a flag matters too, he notes. Four kinds fall out, and the quotations in the list are his:

A common mistake is treating all four alike. Hodgson warns that “managing them all in the same way can lead to pain down the road.” A release flag that lives for a year is debt. A kill switch that someone deletes in a well-meaning cleanup is an outage waiting for its day.

A flag can also change kind. Hodgson follows a recommendations panel from release flag to experiment to ops flag, and each move changes where its configuration lives and who manages it. Record the kind when a flag is born and update it when it changes, because the kind decides who may flip the flag and how long it lives.

One Decision Point, Injected

Hodgson names another common mistake: coupling “the place where a toggling decision is made (the Toggle Point) with the logic behind the decision (the Toggle Router).” An if on the same flag in eleven files is eleven places to forget when the flag goes away. Decide once, in front of the code that changes, and inject the result. Where a toggle point will live long, he recommends “some sort of Strategy pattern” instead of a conditional.

Here is the wiring in an ASP.NET Core app whose flags live in Azure App Configuration. The app signs in with DefaultAzureCredential, which tries a chain of credentials: in Azure, usually the app’s managed identity; locally, a developer’s own sign-in. It loads the feature flags and checks them for changes at most every 30 seconds, which is also the provider’s default:

var config = builder.Configuration;
var endpoint = config["AppConfig:Endpoint"]!;
var refresh = TimeSpan.FromSeconds(30);

// Flags live in App Configuration
// and refresh without a redeploy.
config.AddAzureAppConfiguration(o => o
    .Connect(new Uri(endpoint), new DefaultAzureCredential())
    .UseFeatureFlags(f => f.SetRefreshInterval(refresh)));

builder.Services
    .AddAzureAppConfiguration()
    .AddFeatureManagement()
    .WithTargeting();

builder.Services
    .AddHostedService<FlagCheck>()
    .AddScoped<NewPricing>()
    .AddScoped<LegacyPricing>()
    .AddScoped<IPricing, PricingRouter>();

var app = builder.Build();

// Checks for flag changes as
// requests arrive, at most once
// per refresh interval.
app.UseAzureAppConfiguration();

In that code, the refresh is driven by requests. Microsoft’s provider documentation says the middleware “only refreshes when both conditions are met: an HTTP request is received and the refresh interval has elapsed.” The middleware starts the refresh in the background and does not wait for it, so the request that triggers a refresh may still see the old value. A worker service with no requests has to call TryRefreshAsync itself, or its flags never change. FlagCheck, a startup check, comes up again under flag debt.

The decision point is one class. It implements the same interface as both engines, so callers ask for an IPricing and never see the flag:

// The one place the flag is read.
public sealed class PricingRouter(
    IVariantFeatureManagerSnapshot flags,
    NewPricing next,
    LegacyPricing legacy) : IPricing
{
    public async Task<Quote> PriceAsync(
        Order order, CancellationToken ct)
    {
        var flag = FeatureFlags.NewPricingEngine;
        IPricing pricing =
            await flags.IsEnabledAsync(flag, ct)
                ? next : legacy;
        return await pricing.PriceAsync(order, ct);
    }
}

// Delete with LegacyPricing and
// the flag when the release ends.

The router reads the flag through IVariantFeatureManagerSnapshot, which, Microsoft’s feature management documentation says, “caches the first evaluated state of a feature during a request.” So a refresh in the middle of a request cannot send part of it to each engine. In my test, a configuration change between two reads in one scope reached the plain feature manager but not the snapshot.

When the release is done, one pull request deletes the router, the legacy engine, and the flag’s constant and record, and registers NewPricing as IPricing. For variant flags, the library has the router pattern built in: WithVariantService<T> picks an implementation by the variant a user is allocated.

Name flags as constants, never as strings typed in place, because a typo in a flag name does not throw. In Microsoft.FeatureManagement 4.8.0, a name with no definition reads as off, and the library logs the miss only at debug level. Whether off hides a feature or exposes it depends on the flag. Misspell NewPricingEngine and the new engine silently never runs; misspell a flag named DisableNewSearch and the switch can never be thrown.

Constants make every call site use one spelling, and a misspelled reference to one does not compile. For definitions missing from the store, set IgnoreMissingFeatures to false in development and test environments, and the library throws instead. In my test, it threw a FeatureManagementException: “The feature definition for the feature 'NewPricngEngine' was not found.”

Rolling Out in Rings

A rollout in rings widens the audience in steps: the team, a named group, 5 percent of users, 50 percent, then everyone. Microsoft’s Targeting filter expresses each ring directly: named users, groups with their own rollout percentages, a default percentage for everyone else, and exclusions that override the rest.

One requirement matters more than the ring sizes: the rollout has to be sticky by user. The same person must get the same answer on every request, or the feature flickers on and off in front of them. The Targeting filter does this by hashing the user ID together with the flag name.

I checked it on 10,000 synthetic users. At 5 percent, 495 got the feature; at 50 percent, 4,936 did, all 495 among them. Among the first 1,000 users, none saw an answer change across ten evaluations. Raising the percentage only adds users. Because the flag name is part of the hash, each flag draws its own cohort: another flag at 5 percent shared 24 of its users with this one.

The library’s other percentage filter does not work this way. Microsoft.Percentage, as its source code puts it, activates a feature “based on a random percentage,” with a new draw on every evaluation. Microsoft’s reference describes one example as enabling a feature “for 50 percent of users.” In my test, though, one caller at 50 percent saw the answer change 486 times in 1,000 calls. That suits spreading work or sampling, not a feature a person can see.

Stickiness rests on the user ID, and two cases undo it. The first is the ID itself. In ASP.NET Core, the default targeting context comes from the signed-in user: Identity.Name becomes the user ID, and role claims become the groups. If that name can change, or two users can share it, the rollout follows the name, not the person; supply your own accessor with a stable ID.

The second case is the anonymous visitor. With no user ID, every anonymous caller hashes to the same value, so they are all out or all in. The flag’s name decides where the switch happens: between 77.8 and 77.9 percent for NewPricingEngine, and between 12 and 12.2 percent for a flag named NewSearch. A 15 percent rollout of NewSearch would reach every anonymous visitor at once.

Give anonymous traffic a stable ID, such as a cookie value, or keep the feature behind sign-in. My wiring above leaves authentication out, so every request it serves is anonymous, and an order’s customer ID reaches the filter only if your code passes it. If it does, mind the snapshot. It keeps one answer per flag for the whole scope, whichever user a later call names, so one scope must not price orders for several customers.

Systems with several services need one more precaution. Each service refreshes on its own schedule, and an idle or cut-off one refreshes late, so for a while two services can answer differently for the same user. When a request crosses services, decide once and pass the decision along with the request.

A Kill Switch You Can Trust

Two questions decide whether a kill switch can be trusted: what happens when the flag store cannot be reached, and has anyone ever thrown it? I measured the first against the App Configuration emulator.

A running app keeps its last configuration. The provider documentation says so: “your application continues to use the cached configuration.” In my test, all 164 reads of the flag during a 100-second outage returned its last value.

Once the store is back, the app can take a while to notice: after a failed refresh, the provider waits before trying again. Its source sets 30 seconds after the first failure, doubling after each further one up to 10 minutes, then adds or subtracts up to a quarter at random. In my test, a flip made just after the store came back took 33 seconds to arrive; after a long outage, the wait can reach 12.5 minutes.

A cold start is different. With the store unreachable, the provider retries for its startup timeout, 100 seconds by default, and then the app fails. In my test it threw a TimeoutException: “The provider timed out while attempting to load.” Pass optional: true to AddAzureAppConfiguration, and the same app starts after the timeout with nothing from the store, so every flag it does not define locally reads as off. A shorter timeout, set through ConfigureStartupOptions, shortens either wait: at 5 seconds, the failure or the start came after 5.2 seconds.

An app started without the store tries again on later refreshes, but the failures at startup count toward the same backoff. Mine asked for a refresh every half second. With a 10-second startup timeout, it loaded its flags 28 seconds after the store came back; with the default 100 seconds, it waited 10.7 minutes. Until then it runs on whatever its local configuration says, and nothing in its responses shows it. Register the provider’s health check, which Microsoft says reports unhealthy “when the last load or refresh attempt failed.”

So decide, for each application, what a cold start without the store should do: fail, which is safe only while other instances keep serving, or start on local defaults. Whether local defaults are safe depends on what off means for each flag.

Hodgson’s convention helps: “enable existing or legacy behavior when a Feature Flag is Off and new or future behavior when it’s On.” Then off is the old pricing engine, which is safe. For an ops flag named after its feature, such as ExpensiveReport, off means no report: degraded, but survivable. For a permission flag, off can take from paying customers what they paid for, and a blanket on gives it to everyone. Keep a local copy of its audience, or fail the start.

OpenFeature, a CNCF incubating project, makes the default explicit. Every evaluation call in its specification takes a default value, and “Flag evaluation calls must always return the default value in the event of abnormal execution.”

Local defaults live in appsettings.json beneath App Configuration, so check how the two merge. .NET merges configuration arrays by position, and the library keeps its flags in an array. In my test, a base file with two flags and an override for the second lost the first flag, and the override did not take effect: both read as off. Version 4.3.0 of the library added merging by flag name; turn on CustomConfigurationMergingEnabled before you layer flag sources.

The second question is the one teams skip. A switch nobody has thrown is a hope, not a control. Throw it once, in production, on a quiet day, and watch what happens; then repeat the drill for each path the switch guards and after the code behind it changes.

Cloudflare had used its kill switch system to mitigate incidents before. On 5 December 2025, it applied one to a rule with an “execute” action for the first time. That path hit an error that, according to the incident report, “had existed undetected for many years.” Customers carrying about 28 percent of Cloudflare’s HTTP traffic were affected for about 25 minutes; my article on graceful degradation tells the story.

Know how fast a throw arrives, too. While requests flow and the store answers, an instance picks up a flip at its next refresh: with the default 30-second interval, up to about half a minute later. All eight flips in my test arrived that way. An idle instance waits for its next request. A kill switch stops a background job only if the job refreshes and rechecks the flag at safe points; a snapshot held for the whole job keeps its first answer.

A Flip Is a Production Change

Flipping a flag changes production with no pull request, no review, no build, and no test run. My articles on ITIL and on SOC 2 both treat a flag flip as a change in its own right. At a minimum, know who may flip a flag, and keep the record of who did.

In App Configuration, who may flip is a role assignment. Microsoft’s App Configuration Data Owner role gives “read, write, and delete access to App Configuration data”; the application itself needs only App Configuration Data Reader. Access keys also work, and they are enabled by default, but a key identifies only itself. Microsoft recommends Microsoft Entra ID and lets you disable access keys, so that every change carries the identity of a person or a service.

The record comes in two parts. The revision history shows what changed and when. Microsoft’s FAQ says it is kept for 7 days in the Free and Developer tiers and for 30 days in Standard and Premium. Who made the change is in the audit logs, which “include caller identity,” according to Microsoft’s monitoring reference. They exist only after you create a diagnostic setting that sends them somewhere, so set one up before the incident.

Neither part records why, and the history expires, so note each flip’s reason and its incident or work item where you keep change records. After an incident, read the flag history the way you read the deployment log.

Not every flag needs a live store. Hodgson writes that “Managing toggle configuration via source control and re-deployments is preferable, if the nature of the feature flag allows it.” The configuration then moves through the delivery pipeline like code, with its review and its history. A release flag whose way back can wait for a deployment can live in source control. Ops flags, and release flags in the middle of a rollout, belong in the store, where the way back is a switch.

Flag Debt: Every Flag Has a Record

Every flag is a second version of the application, and the debt comes due in two places: the record and the tests. A flag is born with its removal: a kind, an owner, a work item, and a date. For a release or experiment flag, the date is the deadline for deleting it. For an ops or permission flag meant to last, it marks a review that decides whether the flag stays. Write them the day the flag is created, the only day anyone remembers why it exists.

Hodgson describes teams that keep “a creation date, a primary developer contact, or even an expiration date for toggles which are intended to be short lived.” He also finds a human-readable description “surprisingly useful,” particularly for flags that people outside the delivery team manage. In code, the constants and the records can sit together, which makes the inventory one list:

public static class FeatureFlags
{
    public const string NewPricingEngine = "NewPricingEngine";
    public const string ExpensiveReport = "ExpensiveReport";

    // The inventory: one record
    // per flag, written the day
    // the flag is born.
    public static readonly FlagRecord[] All =
    [
        new(NewPricingEngine, FlagKind.Release, "pricing-team",
            Due: new(2026, 12, 1), WorkItem: "PRICE-142"),
        new(ExpensiveReport, FlagKind.Ops, "reporting-team",
            Due: new(2027, 4, 1), WorkItem: "OPS-77"),
    ];
}

public enum FlagKind { Release, Experiment, Ops, Permission }

// Due: the date to delete a
// short-lived flag, or to review
// a long-lived one.
public record FlagRecord(
    string Name,
    FlagKind Kind,
    string Owner,
    DateOnly Due,
    string WorkItem);

The store can carry the same fields. App Configuration feature flags take a description and tags, and in my emulator test a flag kept its kind, owner, and removal date as tags.

Then make the date loud. A test that fails the day after a flag’s date turns “someday” into a red build, and a red build gets fixed. Hodgson mentions teams that create such “time bombs” for expiration dates. The record’s constructor already requires a date and a work item, and the second test catches a constant added without a record:

public class FlagRecordTests
{
    // A flag past its date turns
    // the build red: delete it,
    // or review it and move its
    // date.
    [Fact]
    public void No_flag_is_past_its_date()
    {
        var today = DateOnly.FromDateTime(
            DateTime.UtcNow);
        var overdue = FeatureFlags.All
            .Where(f => f.Due < today)
            .Select(f => f.Name);
        Assert.Empty(overdue);
    }

    // Constants and records must
    // match one to one.
    [Fact]
    public void Every_constant_has_a_record()
    {
        var constants = typeof(FeatureFlags)
            .GetFields()
            .Where(f => f.IsLiteral)
            .Select(f => (string)f.GetRawConstantValue()!);
        var records = FeatureFlags.All.Select(f => f.Name);
        Assert.Equal(
            constants.Order(),
            records.Order());
    }
}

I broke the sample five ways. A release flag past its date, an ops flag past its review date, a constant with no record, and a record with no constant each failed exactly one test. A flag with no date did not compile. A test like this can fail on a day when nobody changed anything, and that is the point. The fix is a decision: delete the flag, or move its date after a real review.

One gap remains. A flag in the inventory that the store does not define reads as off, silently. The startup check registered in the wiring logs a warning for each one:

// Warns at startup about each
// flag in the inventory that the
// store does not define: such a
// flag silently reads as off.
public sealed class FlagCheck(
    IVariantFeatureManager flags,
    ILogger<FlagCheck> log) : IHostedService
{
    public async Task StartAsync(CancellationToken ct)
    {
        var defined = new HashSet<string>();
        await foreach (var name in flags.GetFeatureNamesAsync(ct))
            defined.Add(name);

        foreach (var flag in FeatureFlags.All)
            if (!defined.Contains(flag.Name))
                log.LogWarning(
                    "Flag {Flag} is not defined in the store.",
                    flag.Name);
    }

    public Task StopAsync(CancellationToken ct) =>
        Task.CompletedTask;
}

Testing with Flags

Ten flags are 1,024 combinations of your application. Nobody tests them all, and nobody needs to. Hodgson observes that “Most feature flags will not interact with each other, and most releases will not involve a change to the configuration of more than one feature flag.” Choose the configurations on purpose:

The inventory makes these configurations cheap to build, since a test can list every flag and its kind. Each kill switch also needs a test of the behavior it guards, so that thrown means degraded, not broken. If combinations still worry you, my article on combinatorics shows that six test runs cover every pair of ten on/off flags.

Two more rules keep the matrix small. If two flags only work together, they are one flag: merge them, or the untested pair will find you. And test both sides only while both sides exist.

When a flag goes, remove the code first: the check, the dead branch, the constant, and the record, in one pull request. Delete the definition from the store only after that code is deployed everywhere, so that no running instance still asks for it. Never reuse the name for something new: a retired flag given a new meaning is how Knight Capital’s dormant code woke up.

A Checklist

For every flag in your code, answer five questions:

In the code, keep one decision point per flag, flag names as constants, and the Targeting filter, with a stable user ID behind it, for anything a person can see. If the answer to the last question is “nobody knows,” you have found your own Power Peg, the dormant code behind Knight Capital’s loss.

Publilius’s line, applied to a pipeline: it is a bad release that admits of no modification. A flag builds the modification in before you need it, and a date on every flag keeps it from outliving its purpose. My Software Development Principles series has entries on feature toggles, on the feature flag lifecycle, and on dark launches, each with the signs of proper application and of common violations.

Further Reading