OperabilityFeature Flags

Feature Flags: Decouple Deployment from Release

Deploying code to production is irreversible in the short term - when something goes wrong, rolling back requires another deploy, which takes time and may have its own risks. Feature flag allows you to revert fast

Rickvian Aldi·Software engineer·10 min read

Co-authored with generative AI

Problem

Every deploy is a bet. You have tested in staging, reviewed the code, and run the canary. Still, production might finds the bugs that staging misses. When a newly deployed feature causes latency spikes, error surges, or data corruption, the right move is immediate rollback.

But rollback means another deploy: cut a revert commit, merge it, wait for CI, deploy, wait for pods to cycle. In a fast-moving system this might take 5–20 minutes. In a payments or authentication path, 10 minutes of elevated errors is expensive.

The deeper problem is that code deployment and feature availability are treated as the same event. They don't have to be.

Solution

Separate the act of deploying code from the act of enabling behavior. Ship the new code behind an if check that reads from an external switch. Flip the switch in a config store (database row, Redis key, or a service like FeatureHub) to turn the feature on or off in seconds. Recovery is decoupled from the deploy pipeline, so a bad change can be killed without writing a revert commit or waiting for CI.

Motivation

A feature flag is a runtime switch that decides which code path runs.
A kill switch is the simplest variant of feature flag: one boolean that globally disables a feature when something goes wrong.

Why this pattern over the alternatives:

  • vs. redeploying a revert commit. Reverting takes 10–30 minutes (write commit, CI, deploy, pod cycle). A flag flip takes seconds. In a payment or auth path, that delta is money and trust.
  • vs. an environment variable. Env vars require a restart to change. A flag is read at request time from a store, so it changes live with no downtime.
  • vs. a config file in the repo. Config files still require a deploy. A flag store lives outside the deploy pipeline.
  • vs. branching in version control. A long-lived feature branch postpones integration and creates merge pain. Flagged code is merged to main immediately but stays dark until you flip the flag.

Breakdown

Lets walk through simple scenario: you are replacing an old checkout flow with a new one.

Step 1: keep both code paths in the codebase

You don't delete the old function. You add the new one beside it:

function oldCheckout(cart: Cart): Receipt {
  // existing, battle-tested logic
}
 
function newCheckout(cart: Cart): Receipt {
  // new logic, faster, but unproven in production
}

Both ship in the same deploy. The new code is dark (present but never executed).

Step 2: add a flag check at the call site

A call site is wherever the feature is invoked. Wrap it in an if that asks the flag system "should I run the new version?":

async function checkout(cart: Cart, user: User): Promise<Receipt> {
  if (await isEnabled('new-checkout', { userId: user.id })) {
    return newCheckout(cart);
  }
  return oldCheckout(cart);
}

Deploy this. Nothing changes for users yet, the flag is off by default, so every request still goes through oldCheckout.

Step 3: store the flag state outside the code

The isEnabled function reads from a flag store, a small piece of state that lives outside your deployed binary so you can change it without redeploying. A database table is enough to start:

CREATE TABLE feature_flags (
  name        TEXT PRIMARY KEY,
  enabled     BOOLEAN NOT NULL DEFAULT false,
  percentage  INTEGER CHECK (percentage BETWEEN 0 AND 100),
  allow_list  TEXT[],
  updated_at  TIMESTAMPTZ DEFAULT now()
);

One row per flag. enabled is the master switch. percentage controls gradual rollout. allow_list lets specific users in for testing.

Step 4: implement the flag check

isEnabled reads the row and decides:

export async function isEnabled(
  flag: string,
  context: { userId?: string; anonymousId?: string }
): Promise<boolean> {
  // Cache reads, flag checks run on every request, hitting the DB each time
  // would add latency and load. A 1–5 second TTL is fine for most flags.
  const rule = await flagCache.get(`flag:${flag}`, () =>
    db.flags.findByName(flag)
  );
 
  if (!rule || !rule.enabled) return false;
 
  // Allow-list: specific users always get the new path (useful for QA, beta testers).
  if (rule.allowList?.includes(context.userId ?? '')) return true;
 
  // Percentage rollout: hash the (flag, visitor) pair to a number 0–99.
  // A consistent hash means the same visitor always gets the same answer,
  // they don't bounce between old and new flow request-to-request.
  if (rule.percentage != null) {
    const visitorId = context.userId ?? context.anonymousId;
    if (!visitorId) return false;
    const hash = murmurhash3(`${flag}:${visitorId}`);
    return (hash % 100) < rule.percentage;
  }
 
  return true;
}

Caching alone is not enough. Any function that writes a flag change must also delete the cached value immediately (Cache invalidate). Without it, disabling a flag has no immediate effect: requests keep reading the old cached state until the TTL expires.

export async function setFlag(
  flag: string,
  patch: Partial<FlagRule>
): Promise<void> {
  await db.flags.update(flag, patch);
  await flagCache.del(`flag:${flag}`);  // evict stale entry immediately
}

Every flag mutation enable, disable, change percentage, must go through setFlag, not a raw db.flags.update. A stale flag cache is dangerous: it means a kill switch that an engineer just flipped is silently ignored while production keeps running the broken code path.

A hash here just maps the visitor ID to a number deterministically, same input, same output. That's what makes a 10% rollout stable: the same 10% of visitors see the new flow on every request, not a random 10% each time.

Step 5: roll out gradually

You now flip the flag in stages by updating the row, your decision in each flag phases would look like this:

Dark (enabled = false), The default state at deploy. New code ships but never runs. Users are untouched.

Internal (enabled = true, allow_list = ['user-123']), Only the listed users hit newCheckout. Good for QA and internal testing before any real traffic.

Canary (enabled = true, percentage = 1), 1% of real users hit the new path. Watch error rates and latency before widening.

Ramp (percentage = 5 → 25 → 50 → 100), Step the percentage up as confidence grows. Each jump is one SQL update.

Kill (enabled = false), Everyone snaps back to oldCheckout. This only works instantly if the cache entry is invalidated at the same time as the database update (see Step 4). If you update the row without evicting the cache, requests keep reading the stale enabled = true value until the TTL expires — the kill switch appears to work but doesn't.

The kill step is the kill switch. When an alert fires at 3am, an on-call engineer calls setFlag('new-checkout', { enabled: false }) and the feature is off immediately across all instances.

Step 6: delete the flag

Once newCheckout has been at 100% and stable for a few weeks, delete oldCheckout, delete the if check, and delete the flag row. An old flag still in the code is a branch that no one tests anymore, and an attractive surface for accidents.

Feature Toggles (often also referred to as Feature Flags) are a powerful technique, allowing teams to modify system behavior without changing code.

— Pete Hodgson

Use a dedicated service in production

The implementation above is intentionally minimal it exists to show how the pattern works, not to be copied into a real system. In production, you will quickly run into problems this hand-rolled version does not handle: audit logs (who changed which flag and when), a UI for non-engineers to flip flags without a database client, SDKs for multiple languages, streaming updates that push flag changes to every instance instead of relying on cache eviction, and targeting rules more complex than a simple percentage.

Dedicated feature flag services solve all of these. Consider:

  • FeatureHub — open-source, self-hosted, streaming SDK. Free to run yourself.
  • Unleash — open-source with a hosted option. Mature, widely used in enterprises.
  • LaunchDarkly — managed SaaS, fastest to integrate, strongest targeting rules. Paid.
  • Flagsmith — open-source and hosted. Good balance between simplicity and features.

All of these give you isEnabled(flag, context) as their core API, the same shape as the implementation above, so the concept transfers directly. The difference is that they handle the infrastructure you would otherwise need to build and maintain yourself.

Tradeoffs

  • Every flag adds a branch in the code. Two paths to test, two paths to reason about. Flags are technical debt, agree on an expiry date before adding one. Based on my experience creating jira ticket to deprecate flag upon flag creation, so you won't forget to clean up
  • The flag store becomes a dependency on every request. If it goes down, every flag check must have a safe default. Caching is mandatory, not optional.
  • A stale cache makes kill switches unreliable. Caching flag reads is necessary for performance, but every write to a flag must also evict the cached value. If those two operations are not always paired, a disabled flag can stay active until the TTL expires, exactly the wrong moment for that to happen.
  • A flag with a dead path is not a safety net, it is a trap. Both branches the flag controls must be tested, monitored, and actively maintained. The moment one path is no longer reliable, the flag must be deprecated immediately. Do not leave a toggle in the code that can route traffic to a broken path. An engineer under pressure at 3am will flip that switch and trust it works. If it does not, you have made the incident worse, not better. Delete the dead path before that moment arrives.

When NOT to Use

  • Permanent configuration. If the behavior controlled by the flag will never need to vary by user or be turned off after launch, it is not a flag - it is a constant. Use an environment variable or a config file.
  • High-cardinality decisions. Flags work well for O(1) to O(N users) decisions. Using flags to control the behavior of every record in a database (millions of items) is better handled by a schema migration or a column on the record itself.
  • Secret or security-sensitive branching. Feature flags are often readable by engineers and operators. If the branch being controlled is security-sensitive (e.g., bypassing an auth check in an emergency), the flag store must have strong access controls and an audit log. Most lightweight flag stores do not provide this.
  • Performance-critical hot paths without caching. If your flag check is in the innermost loop of a high-throughput path and you haven't cached the flag evaluation, the flag lookup becomes a latency contributor. Always cache flag reads.

Read-Through Cache is essential alongside feature flags: flag evaluation must be fast enough to add to every request. Caching flag state with a short TTL (1–5 seconds) keeps evaluation sub-millisecond and avoids hammering the flag store. The two patterns together produce a safe, fast feature deployment system.

References

Get essays in your inbox

Practical deep-dives on software craft, career leverage, and building things that matter.