A feature flag was fully rolled out six months ago, yet both branches of `if (flags.newCheckout) { ... } else { ... }` are still in the production bundle. What determines whether a build can delete the dead branch, and what would you change?
answer
- removal needs a build-time proof
- runtime value, therefore both branches
- constant folding, then unreachable code
- the branch retains a whole subtree
- stale flag is the actual defect
basics
~20 sDead-code elimination is static: a branch is removed only when the condition folds to a constant at build time. A flag read from runtime configuration is opaque, so both branches ship. The real fix is retiring the flag and deleting the code, not making the build smarter.
solid answer
~50 sA build can only delete a branch it can prove is unreachable, and that proof has to be available at build time. When `flags.newCheckout` comes from runtime configuration — a fetched JSON payload, a cookie, a global set by the server — the condition is opaque, so both branches are kept and both cost bytes on every load. If the flag were replaced with a literal during the build, the way the conventional `process.env.NODE_ENV` substitution works, the condition would fold to a constant and the minifier would drop the unreachable side. But I would not reach for that first here. The flag has been fully rolled out for six months: the correct change is to delete the losing branch and the flag with it. Dead-code elimination is a safety net for build-time constants, not a substitute for flag hygiene — and every permanent flag is a branch someone has to reason about later.
code
javascript · 12 lines// Not removable: the condition is only known at runtime
const flags = JSON.parse(globalThis.__CONFIG__ || '{}');
if (flags.newCheckout) {
console.log('new checkout');
} else {
console.log('legacy checkout');
}
// Removable: the build substitutes a literal, the condition folds
if (process.env.NODE_ENV !== 'production') {
console.log('dev-only warning');
}go deeper
Know that code behind an if statement still ships even when the condition is always false in production, unless the build can see the value as a constant.
Explain constant folding: the build substitutes a literal, the condition collapses, and the unreachable block is dropped — and explain why a value fetched at runtime cannot take that path.
Show the judgment: name the folding mechanism, then argue that a six-month-old rolled-out flag should be deleted rather than converted, and point out that the retained subtree behind the branch is where the bytes really are.
Own flag lifecycle as a policy — owners, expiry dates, a report of uniform-valued flags — and be explicit that build-time flags multiply artifacts and fragment caching, which is a cost the organisation pays, not just the bundle.
## What "dead code" means to a build Dead-code elimination removes statements a build can prove will never execute. The proof is entirely static: it comes from constants visible in the module graph after substitutions, never from knowledge of how the application will be configured in production. If a condition's value is not knowable while the build runs, both sides are reachable as far as the build is concerned, and both are emitted. This is why the shipped bundle carries code for a decision that was settled six months ago. Nothing is broken. The build is being correct about a question it genuinely cannot answer. ## The two kinds of flag ```javascript // Runtime flag: value arrives with the app, both branches ship if (flags.newCheckout) { renderNewCheckout(); } else { renderLegacyCheckout(); } // Build-time constant: folds, and the unreachable branch is dropped if (process.env.NODE_ENV !== 'production') { installDevWarnings(); } ``` The second form works because the build replaces `process.env.NODE_ENV` with the string literal `'production'` before minification. The condition becomes `'production' !== 'production'`, which folds to `false`, and the block behind it becomes unreachable and disappears. This is the mechanism behind development-only warnings vanishing from production builds, and it is available to your own flags too. The first form cannot fold, and no amount of build configuration changes that. A value fetched at runtime is a value the build never sees. ## Why converting the flag is usually the wrong first move It is tempting to answer "make it a build-time constant." Sometimes that is right, but it has real costs you should be able to name: - **You lose the flag.** A build-time flag cannot be flipped without a rebuild and a redeploy, which removes the kill-switch property that made the flag worth having. - **You multiply artifacts.** If two audiences need different values, you now build and cache two bundles. That splits your CDN cache and complicates rollback. - **It hides staleness.** A folded flag stops costing bytes, so nobody notices the branch is still there. The code rots quietly instead of loudly. For a flag that has been fully on for six months, none of that is needed. The change that actually helps is deletion: remove the losing branch, remove the condition, remove the flag definition, remove the tests that exercise the dead path, and remove the flag from whatever service serves it. The bytes go away and so does the branch a future reader would otherwise have to think about. ## Where the bytes actually are Note that the `if` statement itself is trivial. The cost is whatever the dead branch pulls in: a legacy checkout component, its validation helpers, a payment SDK you no longer use, an experiment's analytics calls. Deleting the branch is what lets the module graph drop all of that, because those modules lose their last reader. This is the connection between flag hygiene and bundle size — a stale flag is not a few bytes of condition, it is an entire retained subtree. That also gives you the diagnosis path when you suspect stale flags are costing you: look at what the build kept for the entry, find modules whose only reachable reference is inside a flagged branch, and check whether that flag still has two live values. ## The operational discipline Good teams treat every flag as having an expiry date from the day it is created. A short-lived rollout flag is a fine thing; a flag that has survived a year is technical debt with an owner. Practical habits: - record an owner and an intended removal date with each flag; - report on flags whose value has been uniform in production for some period — those are ready to retire; - delete the code path in the same change that retires the flag, not in a follow-up ticket nobody schedules; - reserve build-time constants for genuinely build-time distinctions, such as development-only instrumentation, where flipping at runtime was never wanted. ## How to answer this in an interview Lead with the mechanism — elimination needs a build-time-constant condition, and a runtime lookup is not one. Show that you know the folding trick and can name the conventional `process.env.NODE_ENV` case. Then show judgment by not reaching for it: the flag is stale, the fix is deletion, and the bytes at stake are the whole retained subtree behind the branch, not the branch itself. That combination — mechanism plus the decision not to over-engineer — is what the question is testing.
- Suppose the flag is genuinely still in use and must stay flippable at runtime. Can you avoid shipping both code paths?Yes, by moving the branch's code behind a boundary that is only fetched when the flag is on, so the bytes follow the decision instead of preceding it. That trades a guaranteed download for a conditional one, plus the latency of fetching at decision time. Whether that is worth it depends on how large the branch is and how many users actually take it.
- How would you find stale flags that are costing you bytes today?Cross-reference two lists: flags whose production value has been uniform for a long period, and modules the build retained whose only reachable reference sits inside a flagged branch. The intersection is your removal queue. Doing this once produces a backlog; adding an expiry date to every new flag stops it refilling.
saying these in an interview costs you the question
- Expects the build to remove branches behind runtime configuration
- Says minification will drop the unused branch anyway
- Turns every flag into a build-time constant without noting the cost
- Thinks the wasted bytes are just the if statement
- Leaves a rolled-out flag in place indefinitely