What feature flags are in a microservices system
In a single application, a feature flag looks like an if statement. In a microservices system it is better understood as distributed runtime configuration: a value evaluated by many independently deployed services, often at different moments, and changed outside any deploy pipeline. That shift is what makes flags useful, and it is also what makes them risky.
The core benefit is separating deploy from release. Code ships dark, already present in production but unreachable. Exposure then becomes a runtime decision that can be widened to more users, narrowed back, or reversed entirely, all without rebuilding or redeploying a service. For backend and platform teams running dozens of services on independent release cadences, this removes the need to coordinate deploys just to coordinate behaviour.
Flags are not all alike. Treating them as one category is how organisations accumulate flags nobody dares delete.
| Flag type | Purpose | Owner | Review | Expiry |
|---|---|---|---|---|
| Release | Hide unfinished or newly shipped code | Feature team | Code review | Remove after full rollout |
| Experiment | Compare variants against a metric | Product and feature team | Experiment design review | Remove when the experiment concludes |
| Operational (kill switch) | Disable a costly or failing path under load | Service or platform team | Change-managed like config | Long-lived; audit periodically |
| Permission/entitlement | Gate features by plan or tenant | Product and billing | Business sign-off | Lives as long as the product tier |
The thesis of this guide follows from the definition. A flag system is a production dependency with its own failure modes, including stale values, unreachable evaluation services, and inconsistent decisions between services. It deserves the same design scrutiny as a database.
How flag evaluation works: remote calls, local caches and sidecars
There are three common ways for a service to turn a flag key and a request context into a decision. They differ mainly in where the ruleset lives at the moment of evaluation.
Remote call per evaluation. The service asks the flag service every time. Decisions are always current. The cost is that every flagged code path now carries a network round trip and depends on the flag service being reachable. A slow or unreachable flag service slows or breaks every caller that waits on it.
Local evaluation against a cached ruleset. An SDK in the process holds the rules and evaluates them in memory. It refreshes them by polling or by holding a streaming connection. Polling forces a trade-off. A short interval reduces staleness but multiplies requests across every instance of every service. A long interval reduces load but delays changes. Streaming pushes changes quickly. In exchange, it brings long-lived connections, reconnect and backoff logic, and the question of what to do with changes missed while disconnected.
Sidecar or shared local agent. One process per pod or host syncs the rules, and services query it over localhost. This keeps per-language SDKs thin and reduces the number of upstream connections, but it adds another component to deploy and monitor.
| Approach | Evaluation latency | Behaviour when flag service is down | Staleness |
|---|---|---|---|
| Remote call | Network round trip | Fails or falls back to defaults | None |
| Local cache, polling | In-memory | Serves last known rules | Up to one poll interval |
| Local cache, streaming | In-memory | Serves last known rules | Low while connected |
| Sidecar/agent | Localhost hop | Serves agent's last known rules | Agent's sync delay |
Staleness matters most for a kill switch. A rollout that reaches users a minute late does little harm. A kill switch that takes a minute to propagate means a minute of continued damage.
All three designs share one weakness. Every service depends on the same flag service and sync path. If that path breaks, or a bad ruleset is published through it, every service fails at once instead of one at a time.
+--------------------------------------------------------------------------+
| Flag service (rules store), shared by every service |
| outage or broken sync hits every service at once |
+--------------------------------------------------------------------------+
^ | | |
| | poll or stream | sync |
| v v v
+----------------------+ +----------------------+ +----------------------+
| 1. REMOTE CALL | | 2. LOCAL CACHE (SDK) | | 3. SIDECAR / AGENT |
| Service | | Service + in-proc SDK| | one per pod or host |
| latency: network RTT | | latency: in-memory | | holds synced rules |
| down: fail/defaults | | down: last known | | down: last known |
| staleness: none | | stale: <= poll intvl | | stale: agent sync |
| waits on flag svc | | stream: low staleness| | latency: localhost |
| on every flagged path| | +reconnect/backoff | | fewer upstream conns |
+----------------------+ +----------------------+ +----------------------+
^
| localhost
+----------------------+
Kill switch lands only after propagation: | Service C (thin SDK) |
poll interval, stream lag or agent sync. | evaluates via agent |
+----------------------+
Three flag evaluation architectures compared by where the ruleset lives at evaluation time, showing their latency, behaviour during a flag service outage, staleness, and the shared flag service as a correlated failure point.
Keeping flag decisions consistent across service boundaries
A subtler failure appears even when the flag service is healthy. Service A evaluates a flag at 10:00:01, service B evaluates it at 10:00:02, and a ruleset change lands in between. Caches refresh on different schedules, or each service hashes a different attribute. One checkout request then renders the new pricing UI in A while B computes totals the old way.
There are two common fixes. The first is to decide once at the edge. The gateway or first service evaluates the flags and forwards the results, for example X-Flags: new-pricing=on, so downstream services read the decision instead of evaluating again. The second is to propagate the evaluation context. The edge forwards a stable user or tenant ID through headers or tracing context propagation, and every service evaluates locally against it.
Both approaches depend on stable bucketing. A service computes hash(flag_key + ":" + user_id) mod 100 and enables the flag if the result is below the rollout percentage. The same inputs give the same bucket in every service and on every request. A request ID is the wrong key because it changes on every call, so a 10% rollout would flicker per request for the same user. Putting the flag key in the hash keeps flags independent, so the same 10% of users don't receive every experiment.
The cost is coupling. The header name and format become a contract that every service must honor. Context gets lost in three common places:
- Services that don't forward headers.
- Async consumers reading from queues, where the context must be copied into message metadata.
- Third-party hops that strip unknown headers.
When context is missing, a service should fall back to the flag's safe default rather than evaluating with an empty ID.
import hashlib
ROLLOUT_PERCENT = {"new-pricing": 10}
SAFE_DEFAULT = {"new-pricing": False}
PROPAGATED = ("X-User-Id", "X-Flags") # the shared header contract
def bucket(flag_key: str, stable_id: str) -> int:
# Flag key in the input keeps flags independent; stable ID keeps users sticky.
digest = hashlib.sha256(f"{flag_key}:{stable_id}".encode("utf-8")).digest()
return int.from_bytes(digest[:8], "big") % 100
def parse_flags(value: str) -> dict:
# "new-pricing=on,dark-mode=off" -> {"new-pricing": True, "dark-mode": False}
pairs = (item.split("=", 1) for item in value.split(",") if "=" in item)
return {k.strip(): v.strip() == "on" for k, v in pairs}
def is_enabled(flag_key: str, headers: dict) -> bool:
# Approach 1: the edge already decided, so read the decision and do not re-evaluate.
decided = parse_flags(headers.get("X-Flags", ""))
if flag_key in decided:
return decided[flag_key]
# Approach 2: evaluate locally against the propagated stable ID (never a request ID).
user_id = headers.get("X-User-Id")
if not user_id:
return SAFE_DEFAULT[flag_key] # context was lost, so do not bucket an empty ID
return bucket(flag_key, user_id) < ROLLOUT_PERCENT[flag_key]
def forward_context(incoming: dict) -> dict:
# Copy into outgoing HTTP headers and into queue message metadata for async consumers.
return {k: incoming[k] for k in PROPAGATED if k in incoming}
# Service A and service B compute the same answer for the same user.
req = {"X-User-Id": "user-48213", "X-Request-Id": "7f3c9a1e"}
print(is_enabled("new-pricing", req), is_enabled("new-pricing", forward_context(req)))
A service first honors a flag decision made at the edge. If there is none, it hashes the flag key with a propagated user ID to get a deterministic bucket, and it falls back to the flag's safe default when that context is missing.
Implementing safe defaults, kill switches and progressive delivery
Safe defaults and fallback behavior
Every flag check needs an explicit default in code. The flag service, its cache, or the network between them will eventually be unavailable. When that happens, the default decides what users see. Choose the direction by risk. A new payment path should fail closed and stay off. A flag that gates a critical existing behavior may need to fail open so that an outage in the flag system doesn't cause an outage in your product. Log every fallback evaluation and alert when the rate rises. Otherwise a silent fallback looks exactly like a deliberate decision.
Kill switches
A kill switch is only useful if it works during an incident. That requires four things:
- A safe value compiled into the code. A wrong value in the flag store can't make the switch unsafe.
- No network call at decision time. The service reads the switch from local state that is kept fresh in the background.
- Documented authority. On-call knows they can flip it without asking anyone first.
- Regular drills. Test the switch on purpose, and test the off path too. That path is often the code nobody has run in months.
Progressive delivery
Expand in stages: internal users first, then around 1%, then wider cohorts. Gate each step on SLIs such as error rate and p99 latency. Tag telemetry with the flag state so you can compare the flag-on and flag-off cohorts directly. Agree on rollback thresholds before you start, while nobody is invested in the launch.
Watch for two traps:
- Small cohorts on low-traffic services. 1% of a few hundred requests per minute is too little traffic to separate a real regression from noise.
- Per-tenant bucketing. It keeps each customer's experience consistent, but one large tenant can account for most of your exposure in a single step.
import logging
from opentelemetry import trace
log = logging.getLogger("flags")
# Defaults are compiled into the service, so a bad value in the flag store or a flag-store outage can't change them.
FLAG_DEFAULTS = {
"new-payment-path": False, # fail closed: the new, risky path stays off
"search-result-cache": True, # fail open: existing critical behavior stays on
}
def is_enabled(flag_snapshot, flag_key: str, context: dict) -> bool:
default = FLAG_DEFAULTS[flag_key]
source = "store"
try:
# Reads an in-memory snapshot that a background task keeps fresh.
# No network call happens at decision time.
value = flag_snapshot.evaluate(flag_key, context)
if value is None: # flag missing from the snapshot
value, source = default, "fallback"
except Exception:
value, source = default, "fallback"
if source == "fallback":
# Log every fallback (and alert on the rate) so a silent default
# isn't mistaken for a deliberate rollout decision.
log.warning("flag fallback key=%s default=%s", flag_key, default)
# Tag the active span so error rate and latency can be split by flag state.
span = trace.get_current_span()
span.set_attribute("app.flag." + flag_key, value)
span.set_attribute("app.flag." + flag_key + ".source", source)
return value
# Usage:
# if is_enabled(snapshot, "new-payment-path", {"tenant_id": "tenant-123"}):
# charge_with_new_processor(order)
# else:
# charge_with_legacy_processor(order) # the off path: exercise it in drills
This flag check reads a local snapshot and uses a per-flag default compiled into the code. It is fail-closed or fail-open depending on the flag, logs every fallback, and tags the trace span with the flag value and its source so SLIs can be compared for flag-on and flag-off traffic.
When to avoid feature flags
A flag gives you a rollback path only for code. It cannot reverse effects that have already left the process, and treating it as an undo button is where teams get hurt.
Changes a flag cannot safely gate
- Irreversible data migrations. If the new path rewrites or deletes rows, turning the flag off restores the old code but not the old data.
- Wire-protocol and API contract changes. Once a consumer depends on a new field or format, flipping the producer back breaks that consumer.
- Dependency upgrades. A new library or runtime version is loaded at build or startup time. It is not a branch you can toggle.
Data, schemas and async work
Use expand/contract migrations. Add the new columns or tables first, deploy code that works with both shapes, flip the flag, and drop the old structure only after the flag is retired. Both code paths then run against one schema.
Queues are harder. A message or job written while the flag was on can be consumed after it is turned off, or the reverse. Version your payloads, and make consumers accept both formats until the queues have drained.
Combinatorial state
N flags create up to 2^N code paths, so you cannot test them all. Test the combinations that matter: each flag's default, its target state, and any flags known to interact.
Flips are production changes
A flag flip changes production behavior without a deploy. Restrict who can flip each flag, require approval for high-risk flags, and keep an audit trail of who changed what and when.
Alternatives: canary deploys, blue-green, branch-based releases and plain config
Flags are one of several ways to separate release from deploy. They differ in what they control: a flag controls code paths for a request, while the others control which binary or config a process runs.
| Approach | Rollback speed | Granularity | Operational overhead | Schema/contract changes |
|---|---|---|---|---|
| Feature flag | Seconds, no deploy | Per user, tenant or request | Flag service, cleanup debt | Poor alone; old and new paths must coexist |
| Canary deploy | Minutes, shift traffic back | Per instance or traffic slice | Routing, metrics comparison | Risky if versions disagree on data |
| Blue-green | Fast traffic switch | Whole environment | Double capacity during cutover | Hard: shared database outlives the switch |
| Long-lived branch | Slow, requires revert and redeploy | Whole build | Merge conflicts, drift | Hides integration problems until merge |
| Static config / env vars | Restart or redeploy | Per instance | Low | Fine for settings, not behavior per user |
Canary and blue-green deployments catch problems in the binary itself: memory leaks, startup failures, dependency regressions. A flag can't help when the process crashes before evaluating it. Flags catch problems in the behavior, and only for the users you choose. That makes the approaches complements rather than substitutes.
A common pattern is to canary the new binary with the flag off, confirm it is healthy, then ramp the flag. If something breaks, the signal tells you which layer failed. For schema changes, neither approach is sufficient alone. Use expand-and-contract migrations so every version in flight can read and write the data, and gate only the behavior behind the flag.
Impact on development cost, engineering time and flag debt
Flags save time in three places. Teams can merge unfinished work to trunk behind an off flag, which avoids long-lived branches and the merge conflicts that build up in them. Turning off a misbehaving flag takes effect at the next evaluation, while a redeploy has to wait for builds and rollouts. And because releases are decoupled from deploys, a team can ship code on its own schedule and switch it on when dependent services are ready.
The costs are less visible. Someone has to run and secure the flag infrastructure. Each flag doubles the code paths that need tests. A flag change is a production change that skips code review unless you add an approval step. Flag debt also accumulates: dead branches, nested conditionals nobody can explain, and dormant code that a stray toggle can switch back on months later.
Treat each flag as temporary:
- Give every flag an owner and an expiry date when it is created.
- Track flag age and evaluation counts. A flag that has served one value for weeks is ready to remove.
- When removing a flag, delete both the flag and the losing code path.
- Enforce cleanup. Fail CI or open tickets automatically for expired flags, because voluntary cleanup tends to get skipped.
Illustrative estimate (assumed numbers, not a benchmark). Monthly debt cost = stale flags × hours lost per flag per month × hourly cost. Assuming 40 stale flags, 0.5 hours each and $100 per hour: 40 × 0.5 × 100 = $2,000 per month.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to feature flags microservices, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.