Compatibility
Published
This is the article that four other pages on this site have been pointing at. The pages on recursion, closures, streams and async/await each measured their own subject, found it clean, and then said in as many words that generators and lazy iteration go through separate machinery and were not covered. They were being careful rather than pessimistic, and the caution turns out to have been justified: lazy iteration was the one surface in this whole series where the base configuration -- no options, nothing enabled -- changed what your program did. That was fixed in the default on 20 September 2026 and verified on the live service. What follows is both the measurement that found it and what the default does now.
What was measured
The sample is seven small programs in one file. A for-of loop over a finite generator. A loop that breaks early, with a counter inside the generator recording how many values were actually produced. A generator that pushes a marker as it produces and a loop body that pushes a marker as it consumes, so the interleaving is visible. A hand-written iterable with its own Symbol.iterator. A conversion through Array.from. An iterable with a return cleanup hook, exited early. And an infinite generator consumed by a loop that breaks after three values.
That last one deserves a note on method. It has no assertion attached to it, because reaching the line after the loop is the assertion. An infinite source consumed lazily terminates; an infinite source consumed eagerly cannot. Writing the case that way meant the measurement could not report a comfortable false negative.
Each program was protected in five configurations: the default output target, the modern output target, both gate profiles and the string-transform profile. The modern target and its gate variant came back byte-identical to the original on every one of the seven cases. The default target, its gate variant and the string profile changed four of the seven.
The values themselves were never wrong. A finite generator iterated to completion produced 1,2,3,4 before and after. The hand-written iterable produced a,b,c before and after. If your loops run over finite sources with no side effects and you only look at the values, this defect is invisible, which is precisely why it is worth writing down.
The rewrite, read out of the emitted file
The ES5 target does not support for-of directly, so it lowers it. Until 20 September 2026 the emitted file contained a helper that every rewritten loop called first. Stripped of its generated names, it did this: if the argument is null, or if it already has a numeric length, hand it straight back. Otherwise, if it has a Symbol.iterator, run the iterator to exhaustion, push every value into a fresh array, and hand back the array. The loop itself then became an ordinary indexed for loop over that array. That helper is what DownlevelIteration=False still selects; the default now emits a stepper that pulls one value at a time and closes the iterator afterwards.
Everything follows from that one design. The length check is why arrays are completely unaffected: an array is handed back as itself, untouched, and the indexed loop over it behaves exactly as the original. Strings and array-like objects take the same fast path. So the large majority of real loops in real code go through this transform and come out perfect.
Anything else -- a generator, a Map, a Set, a custom iterable, anything whose values are produced on demand -- took the second path and was fully drained into an array before the loop body executed a single time. For a Map or a Set that is merely wasteful and completely invisible, because they are finite, already in memory, and produce no side effects when read. For a generator it is a different program.
The transform is also where a second, quieter thing went missing. The drain loop called next until the iterator reported it was done. It never called return. There is no place in an indexed loop over a finished array where a cleanup hook could be called, because by the time the loop starts, iteration is already over.
Four measured symptoms of the old lowering, in order of how loudly they failed
The loudest is the infinite source. The protected program did not hang and did not silently produce a wrong answer; it threw RangeError: Invalid array length from inside the drain helper. That is the good failure mode, in the sense that it stops. It is a poor failure mode in the sense that it happens after the process has spent time and memory filling an array it will never use, and the message says nothing about iteration.
The next is the count. The loop that breaks after three values was measured pulling three values from its generator before protection and 100 after -- every value the generator could produce. A break in a rewritten loop exits the indexed loop. It cannot un-produce values that were generated before the loop began. If the generator reads a file per value, calls an API per value, or advances a cursor per value, all of that work happens, and it happens whether or not the loop body ever asks for it.
The third is ordering, and it is the one most likely to be misdiagnosed. The unprotected run interleaved production and consumption exactly as written: produce 1, consume 1, produce 2, consume 2, produce 3, consume 3. The protected run produced all three, then consumed all three. No value was lost and no value was wrong. But any generator whose production step touches shared state that the loop body also touches now sees that state in a different order, and the failure will surface somewhere else entirely, as a data bug with no obvious connection to the loop.
The fourth is the quietest and has no symptom at all at the site of the problem. The iterable with a cleanup hook reported cleanup-ran=yes before protection and cleanup-ran=no after. Nothing was thrown and no value changed. If that hook closes a file handle, releases a connection or clears a lock, the resource simply stays held, and the only evidence is the resource itself, much later.
The modern target was always clean, and the ES5 default is clean now too
All of the symptoms above belong to the legacy lowering: the ES5 default as it stood before 20 September 2026, and what DownlevelIteration=False selects today. The modern target and its gate variant were byte-identical to the original on every case in this sample, including the infinite generator and the cleanup hook, because there is no lowering to do: the modern target emits for-of as for-of and the language runs it.
So the mitigation is a build setting rather than a code change. If your deployment targets browsers and runtimes that support for-of natively -- which is every browser released in roughly the last decade -- selecting the modern output target removes this entire class of behaviour, and removes it for all of the symptoms at once rather than one at a time.
The spec-faithful lowering shipped as the opt-in DownlevelIteration option on 24 August 2026 and became the default on 20 September 2026. It steps the iterator one value at a time instead of draining it, and the emitted loop performs the cleanup call the specification asks for -- return runs on break, on throw and on an early return out of the loop, and an error thrown by the body still wins over one thrown by the cleanup. All four symptoms above were re-measured under it and matched the unprotected original, including the infinite source and the interleaving.
It was opt-in at first for a measured reason, and the measurement has not changed: stepping an iterator costs a function call per element, which benchmarked four to seven times slower than the indexed loop on plain arrays -- the case the vast majority of real loops are. What changed is which side of that trade gets the default. Silently altering what a program does is a worse outcome than a slower loop, and a build that has measured the cost can still ask for the old lowering by name with DownlevelIteration=False, which now also emits a warning. Arrays keep their fast path either way; the price is paid only where the protocol is actually needed. TypeScript makes the same trade-off available with its downlevelIteration flag.
If you are pinned to the older target for a reason, the code-level workaround is to stop asking a for-of loop to be lazy. Drive the iterator by hand with an explicit while loop calling next, which is a shape the transform does not touch, or convert deliberately with something that takes a count. Both are more verbose and both do exactly what they say.
Do not reach for Array.from as the workaround. It converts eagerly by definition, so on an infinite source it fails the same way, and on a side-effecting source it fires everything -- it just does so visibly, on a line you wrote, which is at least honest.
One caveat about this page rather than about your code. The four symptoms above describe the ES5 target as it behaved before 20 September 2026, which is also what DownlevelIteration=False selects today; they are no longer what an ordinary protect does. The fix was verified on the live service on 20 September 2026 by protecting a generator loop that breaks on its first value and executing the result: it returned body1, where the old lowering returned after1,after2,body1. Treat every figure here as a dated measurement, and re-run the counter check on your own build rather than assuming this page is still current -- including this paragraph.
Member renaming reaches the protocol itself
The iteration protocol is a set of names the language reads: Symbol.iterator, then next, then value and done on the object next returns. A symbol is never a rename site, but the other three are ordinary property names, and if a member pattern matches them the language stops finding what it needs.
Measured on the modern target, so that the lowering above is out of the picture: a pattern matching next broke the hand-written iterable with TypeError: undefined is not a function. A pattern matching value and done produced RangeError: Invalid array length, because a done that always reads undefined is always falsy and the loop never ends. Both are loud, immediate and easy to trace.
One arm was neither loud nor immediate. On the modern target, a pattern matching only return left every value, every count and every ordering correct, and changed exactly one thing: cleanup-ran went from yes to no. The declaration in the sample is a quoted string key, and it was still renamed -- the emitted file shows "_0x1":function(){...} where the source says "return". There is no matching access site anywhere in the file to keep consistent, because the caller is the language.
That is worth separating from the keyword-named-member behaviour this site documents elsewhere, which is about an access site being written in quoted form and therefore surviving. Here there is no access site at all. Quoting a declaration key does not protect it, and a protocol method is the one kind of member whose only caller is guaranteed to be someone other than you.
One over-broad pattern, and how to check your own build
The control arm in this sample was supposed to be boring. It matched two names the file owns, items and from, on an object built for the purpose. It threw TypeError: Array._0x2 is not a function, because from is also the name of Array.from, and member renaming is name-based rather than type-aware. A pattern written to describe your own data model reached a built-in constructor's static method in the same file.
That is a general hazard rather than an iteration one, but iteration is where you meet it, because the short conversion names -- from, of, keys, values, entries, next -- are simultaneously plausible field names and real parts of the standard library. Anchor member patterns to names that are distinctive to your application, and prefer a prefix or a suffix over a bare common word.
To check the lowering behaviour in your own build, take the smallest generator you have, put a counter inside it, loop over it with a break, and compare the counter before and after protection. One number, one comparison. If it changed, you are on the default target and the rest of this article applies to you.
To check the protocol behaviour, assert on quantity rather than on the absence of an exception -- count the values a loop actually consumed and compare the runs. A loop that produces the right values in the wrong order, or that holds a resource it should have released, throws nothing at all, so a test that only asks whether an error occurred will pass on every build.
Frequently asked questions
Does obfuscation break for-of loops?
Not for arrays, which are the majority of real loops. On the ES5 output target a for-of loop over an array is rewritten into an indexed loop over that same array and behaves identically, and strings and array-like objects take the same path. The behaviour used to change when the source was lazy -- a generator or a custom iterable -- because the rewrite drained it into an array before the loop body ran. Since 20 September 2026 the default steps the iterator lazily instead, and the drain happens only if you ask for it with DownlevelIteration=False. The modern output target emits for-of unchanged and was byte-identical to the original on every case measured.
Why does my generator run to completion even though I break out of the loop?
On builds from before 20 September 2026 it did not, because the generator was already exhausted before your loop started: the rewrite converted a non-array iterable into an array first, then looped over the array by index, so a break exited the indexed loop rather than stopping production. A loop measured pulling three values before protection pulled one hundred after. Since 20 September 2026 the ES5 default steps the iterator one value at a time and a break stops production as written. The eager form remains available as DownlevelIteration=False, and the modern output target was never affected.
Will an infinite generator still work after protection?
It does now. It did not before 20 September 2026: the rewrite tried to collect every value into an array before the loop began, which cannot terminate, and the measured result was a RangeError reporting an invalid array length -- a failure rather than a hang, but only after consuming time and memory, with an error message that does not mention iteration. The ES5 default now consumes the source lazily, so a loop that breaks after three values terminates normally. The modern output target always ran it correctly, and DownlevelIteration=False still reproduces the old failure.
My iterator cleanup hook stopped running. Is that related?
It used to be, and it was the quietest symptom of the old rewrite. The drain loop called next until the iterator reported it was done and never called return, so a cleanup hook attached to early exit had nowhere to be called from: measured, the hook went from running to not running with nothing thrown and no value changed, and a handle, connection or lock simply stayed held. Since 20 September 2026 the ES5 default performs the cleanup call the specification asks for, on break and on throw. Under DownlevelIteration=False it is still skipped.
Does member renaming affect iteration?
It can, because next, value and done are ordinary property names that the language reads by name. Measured on the modern output target, renaming next broke a hand-written iterable with a TypeError, and renaming value and done produced a RangeError because a done that always reads undefined is always falsy. Symbol.iterator is not affected, because a symbol is never a rename site.
Is quoting a property name enough to protect it from renaming?
No. A quoted declaration key is still a rename site: the cleanup hook in this sample is declared as a quoted string and the emitted file shows it renamed to a generated name. What survives renaming is a quoted access site, which is a different thing and is why some keyword-named platform calls keep working. A method whose only caller is the language has no access site in your file to stay consistent with.
Which loops should I test first after enabling protection?
Any loop whose source produces values on demand rather than holding them: generators, database or file cursors, paginated fetchers, and any custom object with its own Symbol.iterator. Put a counter inside the producer, break early, and compare the count across builds. Loops over arrays, strings and array-like objects need no special attention. On a current build the counts should now match; if they do not, check whether the build sets DownlevelIteration=False, which selects the old eager lowering deliberately.
Related reading