Product & Risk
Published
Most conversations about protecting client-side code are about theft — someone copying an algorithm, lifting a widget, extracting a key. There is a quieter exposure that costs teams more often and gets discussed almost never: modern delivery practice ships the code for a feature weeks or months before the feature is announced, and the bundle describes it in your own words.
Trunk-based development has a publication side effect
The practice is sound and worth keeping. Merge continuously, keep unfinished work behind a flag, deploy whenever you like, and decide separately who sees what. It removes long-lived branches and makes releases boring, which is the entire point.
The side effect is structural. “Behind a flag” means the code is in production and merely not executing. For a server-rendered feature that is fine, because the code sits on your machine. For anything client-side, the flag gates execution, not delivery. The implementation, its strings, its endpoints and its flag name have all been downloaded by every visitor since the day it merged. Nobody had to breach anything; your build shipped it, and your CDN cached it.
What a reader actually gets
This is not a theoretical exposure, and the people who exercise it are not exotic. Competitors watching your product, journalists covering your market, and users who enjoy knowing things early all read production bundles, because it is cheap, legal and unusually reliable. A search for enable, beta, flag or isNew across a minified bundle is a thirty-second job that regularly returns a quarter's worth of plans.
What comes back, in rough order of how damaging it is:
- Flag names, which are specifications. Nobody names a flag
flag_42. They name it enableTieredPricing, showAcmeIntegration, newOnboardingV3. Each one states a decision, an integration partner or a pricing direction in a single readable token.
- The implementation itself. Not a hint that tiered pricing is coming, but the components that render it, the tier names, and the copy — because user-facing strings ship with the code that displays them.
- Endpoints that do not exist publicly yet. The unreleased feature calls something, and that path is a literal in the bundle. Endpoints tend to be more descriptive than flags.
- Timing. Bundles are archived by third parties and diffed. The date a flag appeared, the date its branch grew, the date it flipped — a fairly precise development timeline, reconstructed from outside.
The compounding problem is that this is all in your own vocabulary. An outsider reading your flag names learns how your team thinks about the market, which is worth more than any individual feature.
“Tree shaking removes it” is usually wrong
The common reassurance is that dead code behind a disabled flag gets optimised away. Sometimes true, usually not, and the distinction is mechanical.
A bundler eliminates a branch when it can prove the condition is constant — a build-time define replaced with false, leaving if (false) { ... } for the minifier to drop. That works, and it is a genuinely good technique.
But a flag read at runtime from a service, a config object or a user record is opaque by construction. The bundler cannot know its value, so both branches are retained — which is precisely what a runtime flag is for. Runtime flags and dead-code elimination are mutually exclusive by design. If you can flip it without a deploy, it is in the bundle.
So check rather than assume. Search a production bundle for a flag you believe is disabled. Whether its implementation comes back is the only evidence that counts, and the answer surprises people more often than it does not.
Where obfuscation genuinely helps
Protection changes the economics of the thirty-second search, and that matters more than it sounds.
Identifier renaming removes the vocabulary. enableTieredPricing becomes a meaningless token, so the flag no longer describes itself. Note the limit that applies to any project using a flag service: the key sent over the wire is a string, and strings are data, not identifiers. Renaming does not change it — the next item does.
String protection removes the grep. Moving literals into an encoded table and decoding at use means flag keys, endpoint paths and unreleased UI copy are no longer sitting in the file as plain text. This is the transform that does the most work here, because searching for readable strings is how nearly everyone starts.
Control-flow transformation obscures the shape. A clean if (flags.x) { renderNewThing() } is legible even after renaming; flattening makes the relationship between condition and consequence considerably harder to follow.
Together these turn a keyword search into an actual reverse-engineering exercise. For a competitor casually monitoring your releases, that is often the difference between finding something and not bothering — and the honest framing is exactly that: a cost increase, not a disappearance. The branch is still there. A reader who runs your application and watches its behaviour under a modified flag value can still find it. What you have removed is the cheap path, which is the one nearly everyone uses.
The fix obfuscation cannot provide
If a feature genuinely must not be discoverable — an acquisition-related integration, a pricing change, anything with a legal or market-timing consequence — the answer is not to protect the code better. It is to not ship it.
Evaluate the flag on the server, and deliver the implementation only to sessions entitled to it, as a dynamically imported chunk fetched at runtime. Users without the entitlement receive a bundle that does not contain the feature at all, and there is no level of effort that recovers something absent. This is more work than a boolean, so spend it selectively: most flags do not need it, and the few that do are usually obvious once someone asks the question out loud.
Two adjacent leaks are worth closing at the same time, because a protected bundle in front of either of them accomplishes nothing:
Scope your flag payload. A depressingly common pattern is a protected bundle that then fetches a JSON document listing every flag in the account, including the off ones, readable by any user in the network tab. Return only the flags relevant to that session.
Do not publish source maps. A public source map undoes every transform above and hands back your original names and file structure. This is the most complete way to defeat your own protection, and it happens by accident constantly — your source maps are publishing your source code covers the detection and the fix.
Kill switches deserve a second look
The same reasoning applies in the other direction, and it is easy to miss. If “disabled” means a flag is false and the browser therefore does not call the code, then a user who forces that flag true in their own session may reach the feature. For an unfinished UI that is embarrassing. For something disabled because it was unsafe, mispriced, or restricted to a licence tier, it is a real problem.
A control that matters commercially has to be enforced where the request is served. The client-side flag is what makes the interface tidy; the server-side check is what makes the decision. The same principle governs license checks and every other client-side gate: authority does not survive the trip to the browser.
A twenty-minute audit
Worth doing once, on the bundle currently in production:
- Search it for
enable, beta, flag, experiment, isNew and your internal project codenames.
- Open your flag service's network response and count how many flags a logged-out visitor can see.
- Grep for endpoint paths and check each one against what you have publicly launched.
- Confirm no
.map file and no sourceMappingURL comment is reachable in production.
- For anything that comes back and should not have, decide between two outcomes: protect it, or stop shipping it.
Most teams find something. The useful part is not the individual finding but the shift in default — treating the bundle as a published document, written by your build system, updated on every deploy, and read by people you did not invite.
Frequently asked questions
Can people really find unreleased features in a JavaScript bundle?
Routinely, and often deliberately. Reading production bundles for evidence of unshipped work is an established habit among competitors, technology journalists and enthusiastic users, because it is cheap and reliable. A flag named enableTieredPricing sitting beside a branch that renders three price cards is not a hint — it is a specification, published by your own build.
Does obfuscation stop my feature flags from being readable?
It removes the easy read. Renaming identifiers and moving string literals into an encoded table means a flag no longer announces itself to anyone who opens the file and searches for the word enable. What it does not do is remove the code: the branch is still present and still reachable, so a determined reader who runs the application and watches its behaviour can still map it. Treat obfuscation as raising the cost, not as deleting the evidence.
What is the only complete fix for leaking unreleased features?
Not shipping the code. If the feature's implementation is not in the bundle, there is nothing to read at any level of effort. In practice that means evaluating the flag on the server and delivering the feature's code only to sessions entitled to it, usually through a dynamically imported chunk. Everything else — obfuscation, opaque flag names, dead-code elimination — reduces exposure without removing it.
Will my flag names show up in network responses even if the bundle is protected?
Yes, if your flag service returns them. Many teams protect the bundle and then serve a JSON payload listing every flag in the account, including the ones that are off, which any user can read in the network tab. Scope that response to the flags the session actually needs. A protected bundle in front of an unscoped flag payload protects nothing that matters.
Does tree shaking remove code behind a disabled flag?
Only when the flag is a build-time constant the bundler can prove is false. A flag read at runtime from a service or a config object is opaque to the bundler, so both branches are retained — which is exactly what a runtime flag is for. Teams frequently assume their unreleased code was optimised away when the mechanism that would have removed it never applied.
Is a kill switch also visible to users?
The client-side half of it is, and that is worth thinking about before you rely on it. If disabling a feature means a flag flips and the browser stops calling the code, a user who can force the flag true in their own session may reach a feature you believed was off. Kill switches that matter commercially or for safety need to be enforced where the request is served, not where it is made.
Related reading