Compatibility
Published
Every contract that member renaming can break has another party on the far side of it: a server, a runtime, a config file, a person at a terminal. Saved state is the case where the other party is your own application, one release ago. That sounds like the safest possible arrangement -- both halves are your code -- and it is in fact the most dangerous one, because the far side has already been written, it is sitting on your users' machines, and it is the one party in this whole family that you cannot redeploy.
What was measured
One sample plus a fixture standing in for a record written by a previous release: preferences, a cart, a session, a schema version and a feature flag. The sample reads the record before it writes anything, exactly as a returning user's session does, branches on the stored schema version to decide which migration to run, restores the session, modifies one preference and serialises the whole record back out.
The fixture is not written by the sample. That ordering is the entire experiment: a persistence module that stores a value and reads it back in the same run proves only that renaming is self-consistent. Here the record already exists, authored elsewhere, before a single line of the sample runs.
The base column is clean. All five profiles -- default target, modern target, both gate profiles, string transform -- reproduced the original output exactly, including the migration decision, the session restore and the byte length of the serialised record. Protection on its own does not affect stored state.
The migration branch reads a value that is no longer there
With member renaming on and a pattern matching the schema version key, the sample printed schemaVersion=undefined and its migration decision changed from MIGRATE-2-TO-3 to MIGRATE-FROM-UNVERSIONED.
That is the worst branch in the function. The record is version 2 and needs one specific transformation. The protected build reads no version at all, concludes the record predates versioning entirely, and runs the oldest migration path against data that has already been through it. Migrations are written to be destructive and one-way; they rename fields, split them, drop what they have finished with. Running the wrong one is not a failed read, it is a rewrite of the user's data based on a false premise -- and the result is then saved.
Isolating the session token gives the quiet counterpart. session-valid=true became false, in a build where the token is still sitting in storage exactly where the previous release left it. The user is signed out, the support ticket says the app keeps forgetting them, and the credential is right there in the store the whole time.
Container keys, in contrast, fail loudly. Patterns matching the preferences object, the session object, the cart or the flags object all threw TypeError: Cannot read properties of undefined on first access. The rule of thumb from the rest of this pass holds here too: rename a container and you get a crash at startup, rename a leaf and you get a wrong value that persists.
Writing it back makes the divergence permanent
The sample does what real code does after reading: it changes a preference and serialises the record back out. Under a pattern matching two preference keys, the serialised record grew from 212 bytes to 233, and the check for the original key name still reported it present.
Both spellings are now in the record. The old name still holds the value the previous release wrote; the new generated name holds the value this release wrote. Nothing overwrote anything, because as far as the runtime is concerned these are two unrelated properties on the same object. The store does not get corrupted in a way anyone would notice -- it gets larger, and it accumulates a second copy of each field under a name that means nothing outside one particular build.
This is why the failure outlives the fix. Correct the rename pattern and redeploy, and new writes go to the right name again -- but the records already on disk carry both, and whichever value the fixed build reads is whichever one it happens to ask for. Cleaning that up is a data migration, written after the fact, against records whose exact shape depends on which release each user last ran.
The sharpest result: every value present, every one on the wrong key
The arms above all assume the rename pattern changed between releases. The last experiment in this pass assumes nothing changed except the source, which is the ordinary case, and it produced the sharpest result of the pass.
First, the reassuring half, measured directly: protecting the same input twice produces byte-identical output. Three consecutive runs of the same file with the same options were identical, and the generated names were the same in each. Renaming is deterministic for a given input.
For a given input. The second half of the experiment took a two-property file, protected it, then added one new preference above the existing two -- a one-line feature, the most routine change a release can contain -- and protected that. The generated names shifted by one: the property that was the first generated name became the second, and so on down the file.
Then the round trip, run end to end rather than reasoned about. The earlier build wrote its record; the later build read that record back. It did not crash and it did not report empty preferences. It printed the new preference holding the old theme's value, the theme holding the old density's value, and the density undefined. Every value was present, intact, and attached to the wrong name -- the whole record read one slot out of step. In an interface that means a user's saved theme silently becomes their accent colour, and it happens on the first launch after an update, to users who did nothing but install it.
What this means in practice
Protection does not break persistence. Five profiles, identical output, including the migration decision and the serialised record. The default configuration is not a risk here.
With member renaming on, stored state deserves stricter treatment than any other contract in this family, for one reason: it is the only one where the other party cannot be updated. A server can be redeployed alongside the client. A config file can be rewritten. The record in a user's browser was written by a release you no longer control and will still be there in a year. Every key that reaches storage -- the fields, the containers, the schema version, and above all the version key itself -- belongs in the reserved list and should stay there permanently, because once a name has ever been written to a user's disk it is part of your compatibility surface forever.
There is a cheaper structural answer if you would rather not maintain that list. Serialise through an explicit mapping instead of storing your in-memory object directly: build the record with quoted string keys on the way out and read it with quoted string keys on the way in. String keys are data and are never rename sites, so the stored shape stops depending on your build configuration entirely. That is worth doing for its own sake -- it is the same discipline that lets you change an internal field name without a migration.
The verification takes one run and is the only one in this pass that needs two builds. Keep a record written by your current production release. Point your next protected build at it before shipping, and print every field you read. Values that come back undefined mean a renamed key; values that come back holding the neighbouring field's contents mean the generated names have shifted, which is the failure that will not announce itself any other way.
Frequently asked questions
Does obfuscation break saved state in localStorage or IndexedDB?
Not in the default configuration. A sample that reads a record written by a previous release, branches on a stored schema version, restores a session and serialises the record back out produced identical output on all five protection profiles measured. Stored state only becomes a surface when member renaming is switched on.
Why does my app run the wrong migration after protection?
Because the stored schema version read as undefined, so the migration function concluded the record predates versioning. In the measured run the decision changed from a 2-to-3 migration to the oldest unversioned path, which then ran against data that had already been migrated -- and the result was saved.
Why are users signed out after an obfuscated release?
Because the session token key was renamed while the token in storage still carries the original name. The measured arm went from session-valid=true to false with the credential untouched in the store. It presents as an app that forgets people rather than as an error.
Are generated names stable between builds?
For identical input, yes -- three consecutive protections of the same file were byte-identical. But the names are assigned per build, so changing the source changes them. Adding one property above two existing ones shifted both of their generated names by one position, which is a one-line feature change.
What happens when a new build reads an older build's stored record?
In the measured round trip, the newer build read every value one slot out of step: the newly added preference held the old theme's value, the theme held the old density's value, and the density read undefined. Nothing threw. Every value was present, intact, and attached to the wrong name.
Does writing the record back fix or worsen it?
It makes the divergence permanent. The serialised record grew from 212 to 233 bytes and still contained the original key, because the old and new spellings are unrelated properties as far as the runtime is concerned. The store then carries both, and cleaning it up later is a data migration against records whose shape depends on which release each user last ran.
How should I protect an app that persists state?
Put every key that reaches storage in the reserved list, including the schema version, and keep it there permanently -- once a name has been written to a user's disk it is part of your compatibility surface forever. The stronger option is to serialise through quoted string keys in both directions, which are data rather than rename sites, so the stored shape no longer depends on your build configuration.
Related reading