Feature-Flag-Driven Trunk-Based Development

Photo by Hannes Grobe via Wikimedia Commons (CC BY-SA 4.0)
Deploy is a technical event — the new code is running on your servers. Release is a product event — real users can now see the feature. Feature flags decouple them so you can deploy dark code many times a day and release later by flipping a flag, often as a product decision rather than an engineering one.
They let you merge unfinished work to the main branch safely. Incomplete code paths are wrapped in a flag that is turned off, so main always builds and deploys while the feature stays hidden. This means you can integrate in tiny daily increments instead of maintaining a long-lived feature branch that drifts from trunk.
A kill switch is an operational toggle wrapping a risky dependency — a payment provider, a heavy query, an untrusted third-party API — that defaults to on but can be flipped off instantly. During an incident, on-call disables the failing path in seconds instead of writing and deploying a code revert, turning an emergency into a lower-priority fix.
Give every flag an owner and expiry at creation. Once a release toggle reaches 100 percent rollout and is stable, open a cleanup ticket and aim to remove it within about 30 days, deleting both the flag definition and the dead code branch. Run quarterly audits and let CI warn on expired flags so debt cannot accumulate silently.
OpenFeature is a CNCF incubating project that provides a vendor-neutral specification and SDKs, giving you a standard API in front of any flag backend like LaunchDarkly, Unleash, or a self-hosted store. Coding against it from day one costs almost nothing and lets you swap providers later as a config change rather than a rewrite, avoiding code-level lock-in.

Photo by Hannes Grobe via Wikimedia Commons (CC BY-SA 4.0)
Key Takeaway
Trunk-based development means everyone commits to one main branch in short-lived branches merged within a day or two. Feature flags make it safe: they decouple deploy from release, so half-finished code ships dark behind a flag. Flags also become kill switches. The catch is hygiene — every flag needs an owner, an expiry, and a cleanup ticket.
Long-lived feature branches are where velocity goes to die. You branch off main, work for three weeks, and by the time you open the pull request the trunk has moved a thousand commits ahead of you. The merge is a knife fight. I have lost whole afternoons to conflicts that existed only because two branches drifted apart for too long — code that was individually correct but collectively incompatible.
Trunk-based development flips the model: integrate constantly, in tiny increments, so conflicts never grow larger than a few lines. But that raises an obvious question — if I merge unfinished work to main every day, how do I ship main to production without exposing a half-built feature? The answer is the feature flag, and the discipline around it is what separates teams who love trunk-based development from teams who quietly abandon it.
The single most useful idea here is separating two events people usually blur together. Deploy is a technical act: the new binary is running on your servers. Release is a product act: real users can now see the feature. On a traditional branch workflow these happen at the same instant, which is why deploys feel scary — every push to production is also a launch. Feature flags pull them apart. You deploy code dozens of times a day, dark, while the release is a separate decision made later by flipping a flag, often by a product manager rather than an engineer.
Once deploy and release are separate, the whole risk profile changes. A deploy that only ships dormant code cannot break a user-facing feature, because none of that code runs yet. Rollback stops meaning revert and redeploy; it means flip the flag off — a config change that propagates in seconds instead of a build-and-ship cycle that takes minutes. That speed difference is the entire point.
// A release toggle wrapping an incomplete feature.
// This code is on main and deployed to prod — but dark.
if (await flags.isEnabled('checkout-v2', { userId: user.id })) {
return renderCheckoutV2(cart);
}
return renderCheckoutLegacy(cart); // still the default for everyoneThe branch part of trunk-based development is simpler than people fear. You still use branches — you just keep them alive for hours or a day, not weeks. Cut a branch, make a focused change, open a small pull request, get it reviewed, merge. The trick that makes this work for large features is that you do not wait for the whole feature to be done. You merge a vertical slice behind a flag that is off, then the next slice, then the next. Main always builds, always passes tests, always deploys — the feature just is not visible until the flag says so.
Pair short-lived branches with a merge queue. It rebases and tests each pull request against the current tip of main before merging, so you never land a change that passed CI against a stale trunk but breaks against today's. This is what keeps main green when a dozen people integrate daily.
A common mistake is treating every flag identically. The taxonomy popularized by Pete Hodgson and adopted across the industry splits flags by how long they should live and how dynamic they need to be. A release toggle exists only to hide unfinished work and should die within days. An operational toggle — a kill switch — might live for months as a safety valve. Mixing these mental models is how you end up with a five-year-old release toggle nobody dares delete.
| Flag type | Purpose | Lifespan |
|---|---|---|
| Release toggle | Hide incomplete work merged to trunk | Days to weeks |
| Operational (kill switch) | Disable a subsystem fast during an incident | Months, long-lived |
| Experiment | A/B test — route cohorts to variants | Length of the experiment |
| Permission | Gate features by plan, role, or entitlement | Long-lived, part of the product |
The operational toggle is the one I would fight to keep even in a codebase allergic to flags. Wrap any risky dependency — a new payment provider, an expensive report query, a third-party API you do not fully trust — in a flag that defaults to on but can be flipped off instantly. When that dependency starts timing out at 2am, you do not wake up an engineer to write a revert. On-call flips the switch, the system sheds the failing path, and you debug in the morning. A kill switch turns a page-someone-now incident into a look-at-it-later one.
A flag is a runtime branch, which means both sides must actually work. Untested off-paths are a classic trap: the kill switch you flip during an incident routes traffic into code that has not run in production for months. Test both states in CI, and exercise your kill switches on a schedule so you know they still work before you need them.
Every feature flag is a conditional, and every conditional you never remove is permanent technical debt. A codebase with hundreds of stale flags becomes a combinatorial nightmare: the number of possible states doubles with each flag, and no test suite covers them all. LaunchDarkly and Unleash both build entire product surfaces around flag lifecycle precisely because teams are terrible at cleanup. The fix is not heroics — it is treating removal as part of finishing the feature, not a someday project.
On the tooling side, OpenFeature is worth knowing about. It is a CNCF incubating project — a vendor-neutral specification and set of SDKs that put a standard API in front of whatever flag backend you use, whether that is LaunchDarkly, Unleash, GO Feature Flag, or a self-hosted store. You write your code against the OpenFeature interface once, and swapping providers later becomes a config change rather than a rewrite. For a self-hosted setup, that portability is exactly the kind of lock-in insurance I want.
Adopt OpenFeature even if you start with a single provider. Coding against the vendor-neutral API from day one costs almost nothing and buys you the freedom to migrate flag backends later without touching business logic — which matters when today's pricing or self-hosting story changes.
You do not need a platform to begin. Start with one release toggle on the next feature that would otherwise sit on a long branch — a boolean read from config is enough to prove the workflow. Add a kill switch to your riskiest dependency. Write down the owner and expiry in the pull request description. The moment the feature is fully live, delete the flag in the same sprint. Do that three or four times and trunk-based development stops being a scary methodology and becomes just how you work: main is always green, deploys are boring, and releases are a decision you make on purpose.