Compatibility

Does obfuscation break labeled statements?

Labeled statements are the least fashionable part of JavaScript control flow and the part most likely to make someone nervous about a transform that renames things and rewrites loops. The concern is reasonable: a label is an identifier, and identifiers are what obfuscation renames. It turns out labels are handled as a separate namespace, and the measurements are clean.

What a label actually is

A label is not a variable. It does not live in a scope, cannot be read, cannot be assigned, and is only ever mentioned by a break or continue that names it. JavaScript keeps labels in their own namespace, which is why you can legally write x: for (const x of xs) and have the label and the binding not collide.

That separation is what makes them safe here. A rename pass works on bindings and on property names. A label is neither, so there is nothing for it to attach to, and a transform that renamed a label without renaming the matching break would produce code that fails to parse rather than code that silently misbehaves.

The measurement, including the interesting case

A nested loop labeled outer, containing both a continue outer and a break outer, was run through the protector in both output targets.

In the modern target the structure is preserved almost exactly: the loop bindings are renamed, the label stays outer, and both jump statements still name it. That much is unsurprising.

The case worth reporting is the default target, which rewrites a for...of loop into an index loop with different bindings and a different header. The label and its two targets moved with it correctly: the rewritten loop is still labeled outer, and the continue outer and break outer inside the inner loop still refer to it. A loop can change shape underneath a label without the label losing its meaning, which is the specific thing you would want to check.

The one case that used to not survive the default target

Everything above concerns control flow: which loop a jump targets, and which iterations run. That was correct on both targets from the start. Closure capture inside a labelled nested loop was not, and the gap is worth describing because it was narrow, it was silent, and it is the kind of thing worth knowing how a vendor handles.

The measurement: a for...of loop labelled outer, containing an inner loop that runs continue outer, with a function pushed onto an array on each surviving iteration. Unprotected, calling those functions afterwards yields 1:1,2:1. The modern target always matched. The default target yielded undefined:2,undefined:2 -- the outer binding gone, the inner one holding its post-loop value. The list of values the loop pushed was correct and the iteration order was correct; only the functions created inside the loop were wrong, so nothing threw and nothing looked unusual.

The cause was the per-iteration copy that gives let its capture semantics when a loop is rewritten into ES5. That copy is made by wrapping the loop body in a function, and a labelled jump cannot cross a function boundary -- so a body containing one was left unwrapped, and every closure in it shared a single binding. Two attempted fixes routed the jump through the wrapper's own return channel and both failed the same way, because the injected return sat physically inside the nested loop and was claimed by that loop's wrapper as soon as it had one.

The fix that worked, in August 2026, stops using the return channel at all. Before any wrapping happens, a labelled jump is desugared into a flag variable plus ordinary unlabelled breaks -- one per loop level it has to escape -- and the flag is read and cleared at the labelled loop. An assignment crosses a function boundary; a labelled jump does not. Both loops then wrap normally and capture is correct. The same sample now returns 1:1,2:1 on both targets, and it is a regression sample rather than a one-off measurement, alongside arms covering three levels of nesting, break as well as continue, and while and do as the intervening loop.

One shape is still declined deliberately: a jump written inside a switch in the labelled loop's body. An unlabelled break there would bind to the switch rather than the loop, so that case is left on the old path, where control flow stays correct and capture may not be. If that is your shape, the modern target avoids it.

Where labels are worth keeping

Since they survive cleanly, the question becomes whether to keep using them, and the honest answer is that the alternatives are often worse for a protected build.

The usual way to avoid a label is a flag variable checked after the inner loop, or extracting the nested loop into a function and returning early. The flag adds a binding and a branch, which is more surface for a reader to follow but also more for you to get wrong. The extracted function is usually the cleanest refactor and has a real advantage under protection: a function boundary is a natural unit for the transforms to work with.

What you should not do is reach for a label as a way to make control flow harder to read. That is what the control-flow transforms are for, and they do it systematically rather than by hand. Hand-obfuscated source is source you still have to maintain.

The related things people ask about in the same breath

Two neighbours of this topic come up together often enough to state briefly. The with statement is not something to rely on: it is forbidden in strict mode and therefore in every ES module, and any code that depends on dynamic scope through it is hostile to every static analysis, including the ones a protector performs. If you still have a with block, removing it is worth doing for its own sake.

The debugger statement is a different matter and worth checking before a release. It survives the transform, so a stray one ships. That is separate from the runtime debug-protection option, which inserts its own checks deliberately; a leftover debugger from a debugging session is just a bug, and a build step that rejects them is a cheap thing to add.

Frequently asked questions

Are label names renamed by obfuscation?

No. Labels live in their own namespace, separate from variable bindings and property names, so the rename passes have nothing to attach to. Measured in both output targets, a label named outer stayed outer and both the continue and the break that named it still referred to it.

Do labels survive when the loop they label is rewritten?

Yes, and this is the case worth checking. The default output target rewrites a for-of loop into an index loop with different bindings and a different header. The rewritten loop is still labeled and the break and continue inside the nested loop still target it correctly.

Should I replace labels with flag variables before protecting?

There is no compatibility reason to. If you want to remove a label for readability, extracting the nested loop into a function and returning early is usually the better refactor, because a function boundary is a natural unit for the transforms. A flag variable simply adds a binding and a branch.

Can I use a label as a cheap form of obfuscation?

It is not worth it. The control-flow options do that systematically, while hand-tangled source is source you still have to read and maintain. Write control flow for the person who has to change it, and let the transforms handle making the shipped form harder to follow.

Does the with statement work in protected code?

It should not be in your code at all. with is forbidden in strict mode and therefore in every ES module, and code that relies on dynamic scope through it resists every kind of static analysis. Removing it is worthwhile independently of protection.

Will a stray debugger statement survive protection?

Yes. A debugger statement in your source is carried through, so one left from a debugging session will ship. That is unrelated to the runtime debug-protection option, which inserts its own checks on purpose. A build step that fails on a leftover debugger statement is a cheap safeguard.

Do closures inside a labelled loop capture the right values?

Yes, on both targets. This was the one measured exception on this page until August 2026: on the default target a labelled continue or break that jumped out of a nested loop left the outer loop's per-iteration bindings uncopied, so functions created inside it read the values left behind after the loop rather than the values from their own iteration. Control flow was correct throughout, which is why it was quiet. A current build copies the bindings correctly on both targets: the labelled jump is rewritten into a flag variable and plain unlabelled breaks before the loop is downlevelled, so no jump has to cross a function boundary. A build produced before the fix still shows the old behaviour, so measure the build you ship.

Related reading