← Back to Logs

How Feature Flags Actually Work

Try the interactive lab for this articleTake the quiz (6 questions · ~6 min)

Feature flags are often introduced as a neat product trick. Turn a feature on for staff, then 5 per cent of users, then everyone. That explanation is not wrong. It is just small. A real flag system is not mainly about hiding a button in a React component. It is a runtime policy system that decides which code path, configuration, algorithm, or downstream dependency a request is allowed to use right now.

That distinction matters because feature flags sit in the middle of several operational problems at once. They influence release safety. They influence incident response. They influence experiment design, observability, and even whether rollback means a git revert or a config change. If the implementation is weak, flags become one more source of nondeterminism and stale code. If the implementation is solid, they give an engineering team a way to separate deploy from exposure and to make risky changes reversible in seconds.

The mechanics are more interesting than the dashboard suggests. Somebody has to store the rules. Somebody has to distribute them to SDKs or services. Somebody has to decide whether evaluation happens on the server, at the edge, or in the browser. Percentage rollout has to be sticky, or users bounce between variants. Dependencies between flags have to be explicit, or the system becomes a maze of nested if statements that nobody can reason about during an incident.

This article looks at those mechanics directly. We will go through the difference between deployment and runtime exposure, the control-plane and data-plane split inside flag systems, how evaluators build context and apply rules, how deterministic hashing makes percentage rollout stable, what variants and experiments reuse from the same machinery, why caching and outage policy matter more than most teams expect, and why old flags become operational debt if they are not removed quickly.

A Feature Flag Is A Runtime Decision Point, Not A Boolean In A Template

The simplest form of a flag is a boolean:

if (flags.newCheckout) {
  renderNewCheckout()
} else {
  renderClassicCheckout()
}

That shape is real, but it is only the surface syntax. Underneath it, a feature flag is usually a named decision point with four parts:

  1. a key, such as checkout_v2
  2. a context, such as user ID, tenant, plan, country, app version, or request origin
  3. a rule set that maps context to an outcome
  4. a fallback if no rule matches or the flag system is unavailable

The outcome is not always true or false. Mature systems often return a variant such as control, v2, canary-eu, or queue-backed. That lets one engine handle several closely related jobs.

Pattern Runtime decision Good for Poor fit for
Release flag Old path or new path Progressive rollout of new code already deployed Long-term product entitlements
Ops kill switch Dependency on or off Incident response and blast-radius reduction Permanent architecture decisions
Experiment flag Variant A, B, or C Measuring behavioural or performance differences Safety-critical emergency control
Entitlement flag Capability present or absent Per-tenant plan gates and staged contract rollout Low-level secret or credential distribution

This is why teams get confused when they talk about feature flags as if they are all the same thing. The UI copy might be similar, but the risk profile is not. A kill switch for a flaky payments dependency wants fast propagation, conservative defaults, audit logging, and a very small number of operators who can touch it. A product experiment wants sticky cohort assignment, variant analytics, and enough isolation that one Berlin user does not swap between A and B across sessions.

The other important point is that a flag is about runtime exposure, not deployment. The code for both branches can be present in production for days before the new branch is visible to real users. That separation is the whole reason teams adopt flags in the first place. Without it, deployment and release are tied together. Every deploy becomes a commitment. With a flag, deployment can simply mean “the code is now available to be evaluated”, while release means “the evaluator is now permitted to choose the new branch for some contexts”.

That sounds like a small wording change. It is not. It changes what rollback means. It changes who is authorised to change exposure. It changes what has to be observable. It changes how incidents unfold at 02:00 when someone needs to stop using a downstream service without waiting for a new container image to roll through Amsterdam, Frankfurt, and Athens.

The Real Architecture Is A Control Plane Feeding Local Evaluators

Most production flag systems converge on the same broad shape even if the product names differ.

There is a control plane where humans or automation define flags, rules, prerequisites, variants, rollout percentages, tags, owners, expiry dates, and audit history.

There is a distribution path that moves those decisions out to applications, edge workers, mobile apps, or browser SDKs.

There is a local evaluator that receives a flag key plus a context object and produces a result quickly enough to be used inside a live request path.

A simplified snapshot for one flag might look like this:

{
  "key": "payments_orchestrator_v2",
  "version": 128,
  "enabled": true,
  "fallbackVariant": "classic",
  "variants": ["classic", "v2"],
  "rules": [
    {
      "name": "staff-override",
      "if": [{ "field": "email", "op": "endsWith", "value": "@lab.example" }],
      "serve": "v2"
    },
    {
      "name": "eu-paid-rollout",
      "if": [
        { "field": "country", "op": "in", "value": ["DE", "GR", "NL", "CZ", "PT"] },
        { "field": "plan", "op": "in", "value": ["pro", "business"] }
      ],
      "rollout": {
        "stickiness": "userId",
        "percentage": 25,
        "serve": "v2",
        "else": "classic"
      }
    }
  ],
  "killSwitch": {
    "field": "paymentsDependencyHealthy",
    "required": true
  }
}

The application that consumes this snapshot does not need to call the control plane for every request. In fact, doing that would be a bad design. If every page render or API request needed a round trip to the flag service, the flag service would become part of the latency budget for the whole estate. Worse, an outage in the flag control plane would turn into an outage in the product path.

That is why real flag systems distribute snapshots or streams of updates to local evaluators.

The distribution options are usually some combination of:

  • periodic polling over HTTPS
  • long-lived streaming connections such as Server-Sent Events
  • bootstrapped config bundled into a page or app start response
  • edge caches or sidecars that fan out a shared snapshot locally
  • signed files for mobile or offline-capable clients

The local evaluator is the data-plane equivalent in this architecture. It does the cheap, repetitive work at high frequency. The control plane does the rare, sensitive, stateful work.

This split is very similar to other configuration systems. Service meshes separate xDS-style control from per-request proxy behaviour. CDNs separate config publication from per-request edge logic. Routing systems separate route management from packet forwarding. Feature flags belong in the same mental family. They look like “just config” only until you watch the effect they have on request-time decisions.

A healthy flag architecture therefore wants these properties:

  • low-latency local reads during normal request handling
  • eventual propagation from central control to local runtimes
  • versioned snapshots so the evaluator knows what it is using
  • auditable writes in the control plane
  • clear fallback rules for stale or unavailable data

If one of those is missing, the system becomes harder to trust. A flag without versioning is hard to debug. A flag without auditing is dangerous in incidents. A flag without local caching is a hidden distributed dependency. A flag without a fallback is not a release control. It is a roulette wheel.

Evaluation Starts With Context And Ordered Rules

Once the application has a local snapshot, the next question is how it evaluates a flag for one concrete request.

The evaluator usually needs a context object. Typical fields include:

{
  "userId": "alex-athens-17",
  "tenantId": "acme-eu",
  "plan": "pro",
  "country": "GR",
  "appVersion": "4.12.1",
  "sessionId": "s-8c3e",
  "paymentsDependencyHealthy": true
}

The context is more important than many teams realise. It defines what kinds of policy are even possible. If the evaluator never sees country, you cannot target by country. If it never sees tenant, you cannot stage a rollout to one tenant. If it only sees a request ID, you cannot build sticky cohorts. The data model of the context constrains the expressiveness of the flag engine.

Evaluation is usually not “check one boolean and stop”. It is more like a small ordered policy interpreter.

Pseudo-code for a common pattern:

function evaluate(flag, context):
    if flag.enabled is false:
        return flag.fallbackVariant
 
    if flag.killSwitch exists:
        if context[flag.killSwitch.field] != flag.killSwitch.required:
            return flag.fallbackVariant
 
    for rule in flag.rules:
        if matches(rule.conditions, context):
            if rule has fixed serve value:
                return rule.serve
            if rule has rollout:
                return rolloutDecision(rule.rollout, context)
 
    return flag.fallbackVariant

The ordering matters.

A deny rule for unsupported mobile versions should usually run before a general percentage rollout. A staff override should usually run before ordinary customer rules. A kill switch tied to a failing dependency should usually short-circuit before variant assignment, queueing, or outbound calls.

This is why feature flag systems age badly when teams scatter evaluation logic across the codebase. One branch checks a boolean from one SDK. Another branch checks the same flag plus a local country rule. A third branch adds one more override in the controller. At that point you no longer have a flag system. You have branching folklore.

A central evaluator creates one source of truth for the rule order and the fallback semantics.

There are still design choices inside that evaluator.

Missing context fields

Suppose a rule says “roll out to paid plans only”, but the context arriving at one background worker does not include plan.

Three possible behaviours exist:

  1. treat the rule as not matched
  2. throw an error
  3. use a default field value such as unknown

Most production systems prefer the first or third option because request paths need deterministic outcomes, not exceptions from missing metadata. But the choice should be explicit. Quietly treating missing context as permissive in one service and restrictive in another creates inconsistent rollout.

Type semantics

A string comparison that treats DE and de differently might be correct. It might also be a bug. A numeric rule that compares app versions lexicographically instead of semantically can produce nonsense like 4.12 being considered older than 4.9. Good evaluators either normalise inputs or constrain the operators so that rule authors cannot easily express broken logic.

Prerequisites and dependencies

Some flags depend on other flags. For example:

  • new_checkout_metrics only makes sense if checkout_v2 is active
  • adaptive_retries is only safe if queue_backed_dispatch is active
  • eu_payment_router_v2 should never activate if payments_dependency_healthy is false

Once you allow dependencies, you need to prevent loops and confusion. A small dependency graph is manageable. A web of cross-cutting prerequisites is not. Mature systems either limit how prerequisites work or make the dependency graph visible in tooling because incident responders need to know which toggle actually controls a behaviour.

Rule evaluation is therefore less like a template variable and more like a compact policy engine. The code path that consumes the answer may be tiny. The machinery that produces a safe answer is not.

Percentage Rollouts Work Because Hashing Makes Assignment Sticky

The moment a team wants “10 per cent of users on the new path”, they need more than ordered rules. They need a way to assign subjects to buckets deterministically.

The bad implementation is easy to write:

const enabled = Math.random() < 0.1

That gives you 10 per cent in aggregate over many decisions, but it is useless for rollout. The same user can get the old path on one request and the new path on the next. Session behaviour becomes inconsistent, cache keys become noisy, and the analytics become hard to interpret.

What you actually want is stickiness. The subject should usually stay in the same cohort for a given flag unless the rule definition changes.

The common pattern is:

  1. choose a stable stickiness key, such as userId, tenantId, or deviceId
  2. combine it with the flag key
  3. hash the combined string with a deterministic non-cryptographic hash
  4. map the hash to a bucket range such as 0..99
  5. compare the bucket to the rollout threshold

For example:

subject = "payments_orchestrator_v2:alex-athens-17"
hash(subject) = 3482716406
bucket = 3482716406 mod 100 = 6

If the rollout percentage is 25, bucket 6 is in. If the rollout later moves to 40, that same user is still in. If the rollout drops to 5, that user moves out only because the threshold changed, not because the hash changed.

Pseudo-code:

bucket = stable_hash(flag_key + ":" + context.userId) % 100
if bucket < percentage:
    serve new variant
else:
    serve control variant

That simple trick does a lot of work.

It gives you:

  • stable assignment across requests
  • stable assignment across instances, regions, and restarts
  • predictable ramp-ups when the percentage increases
  • reproducible debugging when you inspect a specific user or tenant

It also creates failure modes when teams pick the wrong stickiness key.

Request IDs are almost always wrong

If you hash on a request ID, every request can land in a different bucket. That destroys cohort stability. It also means an API client in Prague may hit the old code path for one write and the new code path for the retry of the same logical operation.

Session IDs are sometimes wrong

A session ID may be acceptable for anonymous web traffic where the goal is page-level experience testing. It is poor for long-lived entitlements or backend behaviour because the user can be reassigned when the session rotates.

Tenant IDs can be better than user IDs

If a feature changes shared tenant state such as data model, search index layout, or invoice rendering policy, assigning users individually can be dangerous. One person in a tenant creates data in the new format, another still reads through the old path, and now the rollout breaks within one customer account. In that case the cohort unit should be the tenant, not the user.

This is the deeper rule: the stickiness key should match the isolation boundary of the thing being changed.

If the new behaviour mutates shared state, cohort assignment must usually be based on the shared owner of that state.

Hash choice and bucket granularity

Most systems use a fast deterministic hash such as MurmurHash, xxHash, or a similar non-cryptographic function. The point is even distribution and reproducibility, not cryptographic security.

Bucket granularity is often 100, 1,000, or 100,000 slots.

  • 100 buckets is simple and enough for coarse percentages
  • 1,000 buckets allows 0.1 per cent ramps
  • 100,000 buckets is useful when very large populations make tiny rollout increments operationally meaningful

The evaluator should also namespace the hash by flag key. Otherwise one unlucky set of users could end up in every canary simultaneously because the same global bucket assignment is reused across flags.

In other words, “25 per cent rollout” is not magic. It is a hash function, a stable identity choice, and a threshold comparison. Get those right and the rollout is boring. Get them wrong and the system behaves randomly while looking mathematically respectable.

Variants, Experiments, And Canary Releases Reuse The Same Engine

Once you can return more than true or false, one evaluation engine can serve several rollout styles.

A release canary might return:

  • classic
  • v2

An experiment might return:

  • control
  • compact-banner
  • large-banner

An infrastructure change might return:

  • redis-cache
  • local-cache
  • cache-disabled

Under the hood these are the same operation. The evaluator chooses one variant according to ordered rules, fixed assignments, or percentage buckets.

That is useful because it prevents teams from building separate systems for “feature flags”, “experiments”, and “canaries” when the mechanics overlap heavily.

The differences are mostly in the surrounding discipline.

Release canary

The goal is safe rollout of new code already deployed. You care about:

  • fast rollback
  • operator-visible health metrics
  • dependency protection
  • low overhead in the hot path

Product experiment

The goal is measurement. You care about:

  • stable cohorts
  • exposure logging
  • sample-ratio sanity checks
  • avoiding contamination between variants

Infrastructure path switch

The goal is runtime rerouting or selective activation of a subsystem. You care about:

  • deterministic behaviour under failure
  • compatibility with shared state
  • kill-switch speed
  • clear ownership during incidents

The most common mistake is to treat all of these as equivalent because they all appear as rows in the same dashboard.

For example, a UI headline experiment can tolerate client-side assignment and a short flash of loading state if the business accepts that tradeoff. A flag that switches write traffic from one ledger path to another cannot tolerate arbitrary client-side evaluation, inconsistent assignment units, or vague fallback semantics.

A useful mental checklist for any new flag is:

  1. what state does the new path read or write?
  2. what is the correct assignment unit for that state?
  3. what is the fastest safe rollback mechanism?
  4. what evidence proves the rollout is healthy?
  5. when does the flag get deleted?

If the team cannot answer those questions, the flag is not ready no matter how polished the UI for creating it looks.

Server Side And Client Side Flags Trade Latency For Exposure

Where evaluation happens matters almost as much as how evaluation happens.

Server-side evaluation

A backend service evaluates the flag and sends the already-decided result to the client, or simply runs the chosen path internally.

Advantages:

  • rule logic and internal targeting stay private
  • secure context such as plan, fraud posture, or internal dependency health is available
  • one server decision can cover several downstream operations
  • exposure is consistent for server-rendered pages and API flows

Disadvantages:

  • browser-only UI interactions may still need local variation state
  • the server must propagate decided variants to clients if the UI depends on them
  • personalisation at the edge can be harder if the core service is far away

Client-side evaluation

A browser or mobile SDK evaluates the flag locally.

Advantages:

  • low latency for interactive UI toggles once the config is present
  • less server coupling for purely presentational features
  • easier offline or weak-connectivity behaviour for some mobile cases

Disadvantages:

  • targeting rules may be visible to users and competitors
  • sensitive context cannot safely be exposed to the client
  • first paint can flicker if the page renders before the flag snapshot arrives
  • experiment or entitlement logic may be bypassable if the client is trusted too much

That last point is the one many teams learn the hard way. A client-side flag is a presentation control unless the server independently enforces the corresponding security or entitlement rule. If a user should not be able to access a paid export endpoint, the backend must deny it whether or not the browser hides the button.

A hybrid pattern is common:

  • the server evaluates sensitive or authoritative flags
  • the server embeds a bootstrapped subset of decisions into the HTML or app-start payload
  • the client uses those decisions immediately, then refreshes its local snapshot later

That reduces flicker and avoids exposing the whole targeting model.

A simple server-rendered payload might look like this:

<script>
  window.__BOOTSTRAP_FLAGS__ = {
    checkout_banner_variant: "compact",
    payments_orchestrator_v2: "classic"
  }
</script>

The browser can render immediately from that bootstrap data. A later refresh may update non-sensitive presentation flags. Sensitive infrastructure toggles stay server-controlled.

The evaluation location should therefore follow the trust boundary. If the flag decides something that affects money movement, authorisation, durable writes, or partner traffic, the authoritative evaluation belongs on the server side even if the client also wants a hint for presentation.

Distribution, Caching, And Failure Policy Decide Whether Flags Help During Incidents

A flag system that works only when every network hop is healthy is not much use in production.

The whole point of a kill switch is that it should still work when parts of the estate are under stress. That requirement pushes teams toward careful caching and conservative failure policy.

Consider three distribution models.

Polling

Each instance fetches the latest snapshot every N seconds.

Pros:

  • simple implementation
  • ordinary HTTPS infrastructure
  • easy to reason about version numbers

Cons:

  • propagation delay equals poll interval plus jitter
  • thousands of instances polling can create load spikes
  • a short-lived flag change may miss the moment you need it most

Streaming

Each instance keeps a long-lived connection to receive updates quickly.

Pros:

  • low propagation latency
  • efficient for frequent small changes
  • useful for incident-driven toggles

Cons:

  • more connection management complexity
  • reconnect storms after network blips
  • state handling must be careful about version ordering

A stream event might conceptually look like this:

event: patch
data: {"flag":"payments_orchestrator_v2","version":129,"rolloutPercentage":10}

The SDK must still treat this as versioned state, not just an arbitrary message. If updates arrive out of order after reconnect, the evaluator cannot naively apply the last packet it happened to see.

Bootstrapped and cached snapshots

The application starts from the last known good snapshot, either local disk, memory, edge cache, or a value embedded in the page or deployment artifact.

Pros:

  • fast startup
  • resilience during temporary control-plane failures
  • predictable hot-path latency

Cons:

  • stale data if refresh stalls
  • awkward behaviour if the cached snapshot is too old for today’s risk posture
  • requires explicit policy for cache age and trust

This is where failure policy becomes operationally important.

Fail open, fail closed, or fail last-known-good

Suppose the evaluator cannot refresh from the control plane.

For each flag class, what should happen?

  • Fail open means keep the new path on.
  • Fail closed means force the old or safer path.
  • Fail last-known-good means keep using the cached snapshot until it expires or an operator intervenes.

There is no universal answer.

A marketing-banner experiment can often fail last-known-good for hours with little risk. A flag that gates a risky write path to a bank partner may need to fail closed once the snapshot is older than some threshold. A flag that disables a broken dependency should ideally continue disabling it from cache even if the control plane is down.

This is why good flag systems classify flags, not just names. The outage policy for a cosmetic banner and for disable_card_tokenisation_provider_x should not be the same by accident.

A practical policy record might include:

flag: payments_orchestrator_v2
owner: payments-platform
rollback_variant: classic
stale_after: 10m
on_stale: force rollback_variant

The control plane can store that metadata, but the local evaluator needs the behaviour encoded too. Otherwise the nicest dashboard in the world does not help when the local process is isolated from it.

Signed snapshots and tamper resistance

If the flag snapshot is security-sensitive or widely distributed, integrity matters. Some systems sign snapshots so that SDKs can verify they came from the expected control plane and were not modified in transit or at rest. That is especially relevant for mobile, edge, or semi-offline environments where config may persist longer than one request.

Feature flags are not high drama cryptography, but they are still production control input. Treating that input as unsigned, unaudited text fetched from an arbitrary location is asking for trouble.

Observability Must Follow The Evaluation, Not Just The Pageview

A flag without observability is hard to roll out safely because you cannot prove who saw what.

The minimum useful signal is usually an exposure event or evaluation log that records:

  • flag key
  • flag version
  • chosen variant
  • assignment key or cohort unit
  • reason, such as rule match or percentage rollout
  • timestamp

A backend log line might look like this:

{
  "ts": "2026-06-12T10:14:03Z",
  "flag": "payments_orchestrator_v2",
  "version": 128,
  "variant": "v2",
  "subject": "tenant:acme-eu",
  "reason": "rule:eu-paid-rollout bucket:6<25",
  "requestId": "req-91ad"
}

That information matters for several different jobs.

Rollout health

If error rate rises only for variant=v2, you have a clean lead. If error rate rises globally across both classic and v2, the deploy may have introduced a shared problem outside the gated branch.

Experiment validity

You need to know who was actually exposed, not who merely qualified in theory. Client-side analytics that infer variant from a later page state often miss aborted renders, race conditions, or cached server decisions.

Debugging one user

When a customer in Lisbon reports “the old billing page keeps returning”, support and engineering need a way to inspect the evaluation path. Was the user outside the rollout bucket? On the free plan? Blocked by an app-version rule? Forced back to control because the dependency health check was red?

Safety during incidents

If an operator flips a kill switch, they need confirmation that the new flag version propagated and that affected traffic actually stopped hitting the risky path. Otherwise the toggle becomes ceremony.

This is also where analytics mistakes become dangerous.

A team might measure experiment traffic by looking at page impressions tagged with variant names in front-end analytics. Meanwhile the authoritative server-side flag decided something different for API writes. The dashboards diverge. The team thinks 20 per cent of traffic is on the new path, but 45 per cent of revenue-affecting writes are actually there because the assignment unit differs. That is not a statistics problem. It is a systems-design mistake.

The observability needs to attach to the actual evaluation point that controls the behaviour. Everything else is derivative.

Flag Debt Starts When The Rollout Ends

Most teams understand how to create flags. Fewer teams are good at deleting them.

A temporary release flag that survives for a year stops being temporary. It becomes a permanent branch in the code and a permanent dimension in test coverage.

Each old flag adds costs:

  • more code paths to reason about
  • more combinations to test
  • more chance of dead branches silently drifting out of date
  • more context that incident responders must understand
  • more config rows nobody is confident enough to remove

This is why “we will clean it up later” is one of the most expensive phrases in release engineering.

A flag lifecycle should be explicit from the start.

Useful metadata includes:

  • owner team
  • creation date
  • intended removal date
  • linked rollout or migration ticket
  • safe fallback path
  • dependency notes

Some teams enforce this in code review or flag-creation tooling. Others scan for stale flags and page owners when the expiry date passes. The exact mechanism matters less than the fact that the lifecycle is enforced somewhere.

A compact code annotation can help humans too:

// Flag: payments_orchestrator_v2
// Owner: payments-platform
// Remove after: 2026-07-05 once v2 is 100% for all paid EU tenants
if (variant === 'v2') {
  writeThroughNewOrchestrator()
} else {
  writeThroughClassicPath()
}

The code is still branching, but at least the branch advertises its expected death.

Deletion has its own discipline.

When a rollout is complete, the right cleanup is usually:

  1. choose the surviving behaviour
  2. remove the opposite branch from code
  3. remove the flag definition from the control plane
  4. remove alerting or dashboards that only existed for the rollout
  5. simplify tests so the dead branch does not keep consuming attention

Leaving the old branch in place “just in case” is often worse than doing the cleanup. If you truly need a permanently switchable behaviour, that is no longer a temporary feature flag. It is a configuration knob or routing policy and should be designed, named, and owned as such.

The distinction matters because permanent knobs deserve different documentation and operational expectations. Temporary rollout flags deserve aggressive deletion.

Some Changes Should Never Hide Behind A Flag

Flags are powerful, but they do not abolish architecture constraints.

There are classes of change where a flag helps only if the surrounding system is already designed for reversible coexistence.

Schema changes with incompatible reads and writes

If the new code writes a format that the old code cannot read, a naive per-user rollout may be impossible. You may need an expand-and-contract migration instead:

  1. deploy code that can read old and new shapes
  2. write the old shape or dual-write temporarily
  3. backfill data if needed
  4. switch readers to the new interpretation
  5. only then remove the old path

A flag can control parts of that plan, but it cannot rescue a rollout model that violates data compatibility.

Security boundaries

A client-side flag must never be the only control protecting an administrative or paid capability. The server must enforce the real rule. Hiding a menu item behind a browser flag is presentation. It is not access control.

One-way external effects

If the new path sends messages to a partner, charges cards, or triggers irreversible downstream work, rollback can be limited by the effect that already escaped. A flag still helps reduce future blast radius, but it does not erase writes that already happened. The team needs idempotency, reconciliation, and compensating-process design, not faith in the toggle alone.

Shared-state fragmentation

If two users in the same tenant can land on different variants and that causes them to observe incompatible shared state, the assignment boundary is wrong. The fix is not “be more careful with the flag”. The fix is to roll out by tenant, workspace, account, or another unit that matches the shared state.

This is one reason mature teams ask early whether a change is truly flaggable. The question is not “can we put an if statement here?” The question is “does the system support two behaviours coexisting safely for some period of time?”

Sometimes the answer is yes. Sometimes the correct tool is a migration plan, a maintenance window, or a full deploy-and-revert path instead.

The Right Mental Model Is A Small Policy Engine Attached To Release Workflow

The most useful way to think about feature flags is not as booleans and not as product chrome.

A feature flag system is a small distributed policy engine attached to release workflow.

It has:

  • a control plane where policies are authored and audited
  • a distribution mechanism that moves versioned snapshots outward
  • a local evaluator that makes fast deterministic decisions
  • a hashing scheme that creates sticky cohorts
  • an observability path that proves which variant really ran
  • a lifecycle discipline that removes temporary policy when the rollout is over

Once you see that shape, several practical decisions become clearer.

You stop calling the control plane in the hot path because policy should be distributed, not fetched synchronously for every request.

You stop using unstable identifiers for percentage rollout because a cohort is part of system state, not random decoration.

You stop treating client-side flags as security controls because the client is not the trust boundary.

You stop leaving completed rollout flags in place for months because temporary policy that never dies becomes architecture.

And you start asking better design questions before the flag exists:

  • What is the assignment unit?
  • What is the fallback variant?
  • How quickly must a rollback propagate?
  • Which dependency health signals should short-circuit the new path?
  • What log or metric proves that the evaluator did what we think it did?
  • When do we delete this?

That is the real machinery behind the dashboard toggle.

A flag system earns its keep when it makes releases safer without making behaviour mysterious. The teams that get the most value from flags are not the ones with the most toggles. They are the ones that keep the evaluation model boring, the propagation model observable, the failure policy explicit, and the cleanup ruthless.

Then a feature flag does what people claim it does in conference talks: it separates deploy from release, reduces blast radius, and gives operators a fast lever when reality diverges from the plan.

Without those mechanics, it is just an if statement with a control panel attached.