Feature flags vs feature branches for unfinished work Jump to heading

Work that is not finished has to live somewhere. The two candidate homes are a branch, where it is isolated from everyone and drifting from the trunk every day, or the trunk itself behind a flag, where it is integrated continuously and dormant in production. Both have real costs, and teams tend to choose by habit rather than by comparing them. This page compares them properly, as part of Trunk-Based Development Setup.

When to use this approach Jump to heading

  • A feature will take more than a few days and the trunk moves daily.
  • The work touches code other people are also changing, so divergence is expensive.
  • You want the incomplete work exercised by CI against the current trunk every day.
  • The feature can be made unreachable at runtime β€” most user-facing work can.
  • If the change is a broad structural refactor that cannot be gated at runtime, a branch may genuinely be the only option; then use the syncing discipline to keep it survivable.

Step 1 β€” Compare what each option actually costs Jump to heading

Where each option is expensiveA feature branch costs divergence, a large risky merge and an oversized review, but leaves no runtime complexity behind. A feature flag costs runtime branching and requires deliberate cleanup, but integration risk stays near zero and reviews stay small.feature branchfeature flagdivergencegrows every daynone β€” always integratedmerge riskone large, late eventmany small, early onesreview sizeone enormous diffa series of small onesruntime costnonea branch in the codecleanupdelete the branchmust remove the flag

Neither column is free. The branch defers all its cost to one moment at the end; the flag pays continuously in runtime complexity and requires an explicit cleanup nobody is paged about. Which is cheaper depends mostly on how long the work will take and how much the trunk moves underneath it.

Step 2 β€” Introduce a flag that defaults off Jump to heading

// src/flags.js β€” flags are data, read at runtime, defaulting to off
const FLAGS = {
  // owner: payments-team Β· added: 2026-07-31 Β· remove by: 2026-09-15
  settlementExportV2: process.env.FLAG_SETTLEMENT_EXPORT_V2 === 'on',
};

export function isEnabled(name) {
  return Boolean(FLAGS[name]);
}
// src/settlement/export.js β€” one branch point, as close to the edge as possible
import { isEnabled } from '../flags.js';

export function exportSettlements(rows) {
  if (isEnabled('settlementExportV2')) {
    return exportAsNdjson(rows);        // new path, dormant in production
  }
  return exportAsCsv(rows);             // existing path, unchanged
}

What changed: the new code path exists on the trunk, compiles, and is unreachable unless the environment variable is set β€” so production behaviour is byte-identical to before the merge.

# Prove it is dormant by default
node -e "import('./src/settlement/export.js').then(m => console.log(m.exportSettlements([]).type))"
# Expect: csv

FLAG_SETTLEMENT_EXPORT_V2=on node -e "…"    # expect: ndjson

The comment above the flag is load-bearing. An owner and a removal date turn the flag into a tracked temporary; without them it becomes permanent by default, which is the failure mode that gives flags their bad reputation.

Step 3 β€” Merge dormant code with confidence Jump to heading

# Every merge is small and lands within a day or two
git checkout -b feat/settlement-export-ndjson main
# … implement one slice, behind the flag …
git push -u origin feat/settlement-export-ndjson
# CI must exercise BOTH paths, or the dormant one rots undetected
npm test                                    # flag off β€” the current behaviour
FLAG_SETTLEMENT_EXPORT_V2=on npm test       # flag on β€” the new behaviour
Dormant code must still be testedThe pipeline runs the test suite twice: once with the flag off, verifying that production behaviour is unchanged, and once with it on, verifying the new path works. Without the second run the dormant code compiles but is never exercised, and rots silently until the day it is enabled.every committo the trunkflag offproduction behaviourflag onthe dormant pathboth verified, dailynothing rots in the darka flag whose "on" path is never tested is a branch with extra steps and less safety

SAFETY WARNING β€” a flag that defaults to on, or that is read once at process start and cached, removes the property that makes this safe. Default every new flag to off, verify the default in a test, and make sure the flag can be turned off again without a deploy β€” a flag you cannot switch off during an incident is not a safety mechanism, it is a release with extra ceremony.

Step 4 β€” Retire the flag once the feature ships Jump to heading

# 1. Enable everywhere and let it soak
FLAG_SETTLEMENT_EXPORT_V2=on   # in every environment, for a full release cycle

# 2. Remove the branch point and the old path in one commit
#    (the diff is small because the new path is already the only one running)
git checkout -b chore/remove-settlement-export-flag main
// src/settlement/export.js β€” after removal
export function exportSettlements(rows) {
  return exportAsNdjson(rows);
}
# 3. Verify nothing still references the flag
git grep -n 'settlementExportV2' -- ':!*.md' || echo "flag fully removed"
npm test    # a single path now; the flag-on test variant is deleted too
The flag lifecycle, including the step that gets skippedA flag is introduced defaulting off, the feature is built behind it in small merges, it is enabled progressively, soaks fully on, and is then removed along with the old code path. Skipping the final step leaves a permanent branch in the code that nobody is accountable for.1introducedefaults off2build behind itsmall daily merges3roll outprogressively on4soak fully onone release cycle5removethe skipped stepwithout step 5 the flag is permanent β€” and fifty permanent flags is a test matrix nobody can reason about

Where to put the branch point Jump to heading

The single most consequential decision in a flagged feature is where the condition sits, and the answer is almost always β€œas close to the edge of the system as you can get it”. A flag checked once at an HTTP handler or a job entry point produces one branch in the code and two clearly separable paths. The same feature gated by a dozen conditions scattered through the domain layer produces a combinatorial mess: every one of those conditions is a place the two behaviours can diverge, the tests have to cover each combination, and removing the flag later means finding all twelve rather than one.

When a feature genuinely needs deep changes, the usual fix is an interface rather than more conditions β€” two implementations of the same abstraction, with the flag choosing between them at construction time. That keeps the runtime cost to a single decision, keeps both implementations independently testable, and makes retirement a matter of deleting one class and the branch that selected it.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Is it safe to merge code that does not work yet? Jump to heading

Yes, provided it is unreachable in production and its tests run. Dormant code behind a flag defaulting to off changes no behaviour, and every merge keeps it compiling against the current trunk. That is strictly safer than the same code sitting on a branch diverging quietly for three weeks, where nothing verifies it against anything.

Do feature flags not become their own mess? Jump to heading

They do when nobody removes them. A flag with an owner and a removal date is a temporary branch in the code; one without becomes permanent, and a codebase with fifty stale flags has a combinatorial test surface nobody can reason about. The retirement step is not optional housekeeping β€” it is what makes the technique work.

What about database migrations that cannot be flagged? Jump to heading

Make them additive and deploy them ahead of the code that uses them. A new nullable column or a new table changes nothing for existing queries, so it can ship weeks early. The destructive half β€” dropping the old column β€” happens after the flag is fully rolled out and removed, which is a separate, deliberate change.