What survives
Published
Renaming variables is the oldest thing an obfuscator does, and scope is what makes it safe or unsafe. If a rename ever crossed a scope boundary, closures would be the first casualty and the symptoms would be bizarre. Measured against the real engine in five configurations, every closure case came out identical, including the loop cases that trip up hand-written code.
Why scope is the thing renaming should break
Almost every transform an obfuscator performs is a rewrite of names, and names only mean anything relative to a scope. The same identifier can refer to three different bindings in three nested functions, and a closure exists precisely to keep one of those bindings alive after the function that created it has returned.
That makes closures the natural stress test. A naive rewrite that treated the source as text would merge two bindings that happened to share a name, or split one binding that was referenced from two places, and the resulting bug would not look like a renaming bug. It would look like state leaking between instances, or a counter that resets, or a private variable that suddenly is not private.
It is also the area where developers have the least appetite for surprises, because the module pattern and the counter closure are how a great deal of shipped JavaScript keeps state private in the first place.
What was measured
A single sample was written to exercise the surface in one file: the classic var loop where three closures all observe the final value; the same loop fixed with an immediately invoked function; the let loop where each iteration gets its own binding; two independent counter closures with private state; the module pattern with a private variable, a private function and a public surface; three levels of shadowing checked at every level; a closure over a function parameter; var hoisting observed before and after assignment; function declaration hoisting called before the declaration; two closures sharing one binding where a write through one is visible through the other; a block-scoped let shadowing an outer let; recursion through a named function expression; a closure created inside a callback; and an arrow function closing over a lexical variable.
The file was run in Node, then protected and run again, with the two outputs compared line by line. Five configurations were used: the default ES5 target, the modern target, a realistic renaming build with identifier replacement and global renaming, that same build on the modern target, and a string-table build.
All five matched the original exactly. The var loop still printed the final value three times, which is the correct wrong-looking answer. The immediately invoked wrapper still printed the three distinct values. Two counters still kept separate state. The module pattern still exposed its public surface while its private variable remained unreachable from outside. Shadowing still resolved to the nearest binding at each of three levels.
Why it holds: renaming is a scope-aware operation
The reason is structural rather than lucky. Renaming is performed against a resolved scope tree, not against the text of the file. A binding and all of its references are rewritten together as one unit, and two bindings that happen to share a name in different scopes are separate units that receive different new names.
That is exactly the property a closure needs. A closure is a reference from an inner function to a binding in an outer scope; if the binding and the reference are renamed as a single unit, the closure is preserved by construction. Nothing about the rewrite cares whether the outer function has returned.
The shadowing case is the direct evidence. Three nested scopes each declared a variable of the same name, and the sample printed the value visible at each level. If renaming were textual, those three would collapse into one and the output would change. It did not change, in any of the five configurations.
The same reasoning explains why the module pattern is safe. A private variable is private because it is declared inside a function and never exported, and renaming does not export anything. In the protected output the private binding simply has a shorter name, and the property that made it private is unchanged.
The loop cases, including what the ES5 rewrite does to let
Loop capture deserves its own treatment, because it is the one place where the language itself surprises people and where a downlevel rewrite genuinely has work to do.
The var loop is the famous one: three functions created in the loop body all close over the same single binding, so all three report the value the loop finished with. That is correct language behaviour, not a bug, and protection reproduced it exactly. The immediately invoked wrapper that fixes it, by creating a fresh scope per iteration, also came through unchanged.
The let loop is the interesting case, because let gives each iteration its own binding and the default target is ES5, where let does not exist. The rewrite therefore has to reproduce per-iteration semantics using constructs that do not have them. Measured, it does: the three closures still reported the three distinct values, on the ES5 target as well as the modern one. In the emitted code the loop bindings are given generated names that make the per-iteration structure visible.
This is worth knowing because it is the case people most often assume is broken. It is not, and the sample that proves it takes about six lines to write against your own build.
What the ES5 target genuinely does not preserve
There is one honest limit here, and it is a property of downlevel compilation rather than of this engine.
On the ES5 target, let and const must become var, and the temporal dead zone goes with them. In the original, reading a let binding before its declaration throws a ReferenceError; after an ES5 downlevel, the same read returns undefined. Standard downlevel compilers behave the same way for the same reason, and the modern target does not have the issue at all.
This matters only if you were relying on the dead zone as a runtime check, which is unusual, since it is a development-time guard rather than a production behaviour. If it does matter to your code, the fix is to select the modern target rather than to avoid protection.
Note the distinction from the loop case above: per-iteration binding is preserved on ES5, while the dead zone is not. They are often assumed to stand or fall together, and they do not.
What renaming does and does not achieve here
It is worth being clear about what you gain, because closures are sometimes described as a protection mechanism in their own right.
A private variable in a module pattern is private with respect to your program's other code. It is not hidden from someone reading or debugging the page. A reader can set a breakpoint inside the closure and inspect every binding it holds, whatever those bindings are called. Renaming removes the descriptive names, which is a real cost imposed on casual reading, and it does not make the values unavailable.
The practical consequence is the same split that applies everywhere on the client. Use closures for program structure, use renaming to raise the cost of reading, and keep anything that must remain unknown on a server. A closure over a secret is still a secret in the browser.
Scope, and how to check your own build
This article covers closures, lexical scope, shadowing, hoisting, the module pattern and loop capture, measured on the default configuration and the four variants named above. It does not cover this binding, which is a separate mechanism with its own article, nor generators and lazy iteration, which turn on how a value is consumed rather than on how a name resolves.
Checking your own build is quick. Write a scratch file containing the closure shapes your project relies on, in particular any counters, caches or module-pattern singletons, and print their state after a short sequence of operations. Protect it with the configuration you actually use, run both files under Node, and compare the output.
If you use the ES5 target and want to know whether the dead zone matters to you, add one case that reads a let binding before its declaration inside a try, and print whether you got a ReferenceError or undefined. That single line tells you which target you should be on.
Frequently asked questions
Does obfuscation break closures?
No. Measured across five configurations, every closure case came out identical: counter closures kept separate private state, the module pattern kept its private variable unreachable from outside, and closures created inside callbacks resolved correctly. Renaming is performed against a resolved scope tree, so a binding and all its references are rewritten together as one unit.
Can renaming merge two variables that share a name?
No. Two bindings with the same name in different scopes are separate units and receive different new names. The sample tested this directly with three nested scopes all declaring the same name, printing the value visible at each level, and the output was unchanged in all five configurations.
Does the per-iteration binding that let gives in a loop survive the ES5 target?
Yes, and this is the case most often assumed to be broken. Three closures created in a let loop still reported three distinct values after the ES5 rewrite, not the single final value that a var loop produces. The rewrite reproduces per-iteration semantics using ES5 constructs.
Is anything about scope lost on the ES5 target?
One thing: the temporal dead zone. When let and const become var, reading a binding before its declaration returns undefined instead of throwing a ReferenceError. Standard downlevel compilers behave the same way, and the modern target does not have the issue. It matters only if you relied on the dead zone as a runtime check.
Does the classic var loop capture bug behave the same after protection?
Yes. Three closures created in a var loop still all report the value the loop finished with, which is correct language behaviour rather than a defect. Protection reproduces it exactly, and the immediately invoked wrapper that fixes it also came through unchanged.
Does a private variable in a closure stay hidden from users?
It stays private with respect to your other code, not hidden from a reader. Someone can set a breakpoint inside the closure and inspect its bindings whatever they are named. Renaming raises the cost of casual reading; anything that must remain unknown belongs on a server rather than in a closure.
Related reading