Compatibility

Does Obfuscation Break Feature Flags and Remote Config?

A feature flag is a promise about control. You ship one build, and a payload from a configuration service decides what that build does. The payload arrives as text and becomes an object at runtime, so its property names are fixed by the service, not by your bundle. The reads of those names are in your code, which means a build step that rewrites property names sits directly between the switch and the thing it switches.

What was measured

One file, driven through five protection profiles for the base column and both member-renaming profiles for everything after. It reads a flag document off disk, which stands in for the JSON a configuration endpoint returns: three release flags with enabled switches, a percentage rollout with a bucketing salt, a kill-switch block, a targeting rule keyed on plan and country, and a refresh interval. It then does the six things every flag client does with that document.

The base column is clean. On the default target, the modern target, the gate profile, the modern gate profile and the string-transform profile, the protected build reproduced the unprotected output line for line: which flags were on, which user fell inside the rollout, whether the kill switch fired, what the targeting rule granted, what the refresh interval was, and the flag report at the end. Protecting an application that reads remote configuration does not disturb it.

Everything below is the member-renaming column, where a pattern matches names that arrive from the configuration service. The document is parsed from text, so its keys are built at runtime and are never rewritten. Only the reads move. That asymmetry is the whole story of this page.

The same switch, two guards, opposite answers

The sharpest result in the pass came from a pattern matching a single name: the enabled switch itself. Two functions in the file read it, and they are the two forms every codebase contains.

The first is the opt-in form, which treats a flag as on only when the service says so. It went from true to false for the flag that was live. The feature was shipped, the payload says it is enabled, and the build turned it off for every user.

The second is the default-on form, written by somebody who wanted a missing flag to mean ship it. It went from false to true for the flag that was deliberately disabled. The feature you switched off came back on, in production, with the configuration service still reporting it as off.

Both of those came from one pattern, in one run, four lines apart in the same file. Which direction a renamed flag read fails in is decided by how the guard was written, not by the flag, not by the service, and not by anything an operator can see from the dashboard.

The report says every flag is off while a disabled feature runs

The same arm produced the result that makes this hard to diagnose. At the end of the file the client builds the report an operator reads: each flag name against whether it is on. Before, that report was accurate, with two of three flags on. After, it read all three as off, and the enabled count went from two to zero.

So the evidence and the behaviour failed together, in opposite directions. The report says everything is off. One of the features it says is off is running, because a different guard in the same file read the same missing property and defaulted the other way. An operator looking at that report has no reason to suspect the client at all: a report that says nothing is enabled looks like a configuration problem at the service.

This is the same shape as an earlier measurement in this series, where a dead-letter log printed nothing while jobs were being parked. When the mechanism that reports a subsystem and the mechanism that runs it share a property name, renaming that name takes out both, and the two failures do not have to agree.

A 25 percent rollout that reaches everybody

A percentage rollout is arithmetic over a user identifier, and that arithmetic is built from method calls. The control arm in this sample matched two names that belong to the standard library rather than to the flag payload, which is exactly the accident a broad member pattern makes: short names on option dictionaries collide with names on built-in objects.

The bucket for a test user went from 86 to 0. Every user now lands in bucket zero, so every non-zero rollout percentage covers everybody. Measured directly: a user who was outside the 25 percent rollout came inside it, silently, with the payload unchanged. A staged rollout you believed was reaching a quarter of your users is reaching all of them, and the way you find out is from load or from support, not from an error.

The same arm broke the report of the targeting rule while leaving the rule itself working. The grants line went from naming the granted flag to the string used when nothing matched, because the emptiness test it prints through was itself a renamed read. A rule that fired, and a log line that says no rule fired.

Kill switches, containers and the loud failures

Not everything here is silent, and the difference is structural. A pattern matching the kill-switch block threw immediately, because the code reads the block and then a property on it without a guard. A pattern matching the rollout block threw the same way, and the message named a generated identifier rather than the property the source asks for.

That is the good failure mode, and it is worth naming why it happens: an unguarded container read is loud, because reading a property of undefined throws. The moment somebody writes the defensive version, with an empty object substituted when the block is missing, the same renamed read becomes a silent revert to defaults. Defensive coding around a configuration payload converts a crash into a wrong answer.

The refresh interval shows the same conversion in miniature. Renaming it produced an undefined interval, an is-usable check that correctly went from true to false, and a client that then applied its fallback of 300 seconds instead of the 60 the service asked for. Nothing failed. The client simply polls five times less often, so every flag change and every emergency rollback now takes five minutes to reach users who were promised one.

What to do about it

Protection alone is not the issue: all five profiles reproduced the flag client exactly. The rule is the same one that governs every external contract in this series. If a name is written by something outside your build, it must not be in your member pattern.

For flag payloads that means the switch names, the rollout block and its percentage, the kill-switch block, the targeting keys and the refresh interval. Anchor the member pattern to a private prefix you control rather than enumerating names to avoid, because the enumeration has to be right forever and a prefix only has to be right once. The measured collision with two standard-library method names is the argument: a list of names you thought of does not cover names the language already uses.

Verify by reading a value, not by checking that a key exists. A presence check compares strings against keys and neither side of that comparison is a rename site, so it cannot see this failure. Print the flag verdict for a known user against a value you typed, run the protected build, and compare. A flag client is one of the cheapest things in an application to test this way, because its whole job is to turn a document into a handful of booleans.

And when you review the guards, review both directions. This measurement found the opt-in form and the default-on form in the same file, failing opposite ways, and only one of them looked like a failure at all.

Frequently asked questions

Does obfuscation break feature flags?

Not in the default configuration. A flag client that reads a remote payload, evaluates opt-in and default-on guards, runs a percentage rollout, checks a kill switch and applies targeting rules produced identical output on all five protection profiles measured. It becomes a surface only when member renaming is switched on and the pattern matches names the configuration service writes.

Can a renamed flag read turn a feature on rather than off?

Yes, and both were measured in one run. The opt-in guard, which requires the service to say enabled, turned a live feature off. The default-on guard, which treats a missing flag as on, turned a deliberately disabled feature back on. The direction is decided by how the guard is written, not by the payload.

Why does my flag dashboard say everything is off?

Because the report is built from the same renamed reads as the behaviour. In the measured run the operator report went from two flags on to zero, while a feature the payload had disabled was running. The report and the behaviour fail together and do not have to agree, which is why a client-side check is worth more than the dashboard here.

Does a percentage rollout still work after member renaming?

Not reliably. A pattern that also matched two standard-library method names used in the bucketing arithmetic collapsed every user into bucket zero, so a 25 percent rollout covered every user. The payload was unchanged and nothing was thrown; the only signal was that a user outside the rollout came inside it.

Why did my kill switch throw instead of failing quietly?

Because the sample reads the kill-switch block and a property on it without a guard, and reading a property of undefined throws. That is the good outcome. If the code substitutes an empty object when the block is missing, the same renamed read becomes a silent revert to the default, which is how a kill switch stops firing without any error.

How much slower can a rollback get?

As slow as your fallback. Renaming the refresh interval made the client read undefined, fail its own is-usable check correctly, and apply a 300 second fallback in place of the 60 seconds the service asked for. Every flag change, including an emergency rollback, then takes five times as long to reach users.

How should a flag client be tested against a protected build?

Print a flag verdict for a known user and compare it against a value you typed by hand, then run the protected bundle and compare again. Do not test with a presence check: comparing key names against strings is blind to renaming, because neither side of that comparison is a rename site.

Related reading