Compatibility

Does Obfuscation Break Async Iteration?

This page exists because another one refused to guess. The article on async and await measured its own subject, found it clean, and then said plainly that asynchronous iteration goes through different machinery and was not measured there. Separately, the article on iterators and generators measured the synchronous protocol and found a real defect in it. Between them they left an obvious and uncomfortable question open: if synchronous lazy iteration is rewritten in a way that changes behaviour, what happens to the asynchronous kind?

What was measured

The sample is eight arms in one file, and most of them assert on ordering rather than on values, because ordering is the thing that a rewrite is most likely to disturb. An async generator producing three values, with a trace recording each production and each consumption as it happens. A loop that breaks early, with a counter inside the generator. A hand-written async iterable implementing Symbol.asyncIterator by hand, including a return cleanup hook. A for await loop over an array of promises. A rejected promise consumed by the loop. Delegation with yield* from one async generator into another. And a manual drive of the protocol, calling next directly and inspecting what comes back.

Each arm was protected in five configurations: the default output target, the modern output target, both gate profiles and the string-transform profile. All eight arms came back identical to the original on all five. The interleaving trace is the one to look at: produce:1|consume:1|produce:2|consume:2|produce:3|consume:3|generator-done, unchanged on every profile. Values were produced one at a time and consumed one at a time, in the order written.

The early-exit arm matters just as much. Breaking out of a for await loop after three values left the generator having produced exactly three, before and after protection, and the cleanup hook on the hand-written iterable ran in both cases.

The contrast with synchronous iteration is the point

The synchronous surface behaves differently, and the difference is worth being precise about because the two look so similar in source code. On the default output target a synchronous for-of loop over a non-array iterable is lowered into a helper that drains the iterable into an array first, then loops over that array by index. For a generator that means every value is produced before the loop body runs once, a break cannot un-produce anything, and the cleanup hook is never called. That is a tracked defect and this site covers it in its own article.

Asynchronous iteration is not lowered that way on either target, and the measurements say so directly: the interleaving survived, the early-exit count survived, and the cleanup hook ran. A drained-then-looped rewrite could not have produced an alternating trace, and it could not have left the generator having produced only three of a hundred available values.

The mechanical reason is that there is no eager equivalent to reach for. A synchronous iterable can be collected into an array by a helper that runs to completion before the loop starts. An asynchronous one cannot be collected without awaiting, and a helper that awaited everything up front would have to be async itself and would change the shape of the surrounding function. So the lowering that causes the synchronous problem has no counterpart here.

The practical consequence is a small inversion of the usual advice. Where this site recommends the modern output target as the fix for the synchronous lowering, async iteration needs no such fix -- it measured clean on the default target too.

Member renaming reaches the protocol, and the boundary is familiar

The async iteration protocol is a set of property names the language reads on your behalf: Symbol.asyncIterator, then next, then value and done on the promise that next resolves to. A symbol is never a rename site. The other three are ordinary property names.

A pattern matching next produced TypeError: undefined is not a function. Loud, immediate, and thrown at the loop rather than somewhere downstream. The failure was confined to the hand-written iterable, and that boundary is the same one this site has measured in several unrelated places: an async generator's protocol is implemented inside the engine, where a member pattern cannot reach it, while an adapter you wrote by hand is ordinary code with ordinary property names.

Matching value and done instead is the worst result measured anywhere in this series, because the program neither fails nor finishes. The first three arms printed correctly -- values, interleaving and the early-exit count all intact, because they run on async generators the engine implements. Then the process reached the hand-written iterable and hung. No exception, no output, no exit; it was still running when the measurement was cut off after fifteen seconds.

The mechanism is worth stating because it explains why nothing is reported. The hand-written next resolves to an object carrying value and done, and those two names were renamed on it. The loop reads done, gets undefined, and undefined is falsy -- so the loop concludes that iteration is not finished, asks for another value, and does so forever. A condition that is never true is not an error, and a loop that never ends has nothing to report.

That failure mode deserves a different response from the others on this page. A throw shows up in any smoke test. A hang shows up as a build that never completes, a request that never returns, or a worker that pins a core, and the stack trace you eventually capture points at the loop rather than at the pattern that caused it. If you rename members and you have hand-written async iterables, put a timeout around the tests that drive them.

One arm was silent, and it is the same shape the synchronous article found. A pattern matching only return left every value, every ordering and every count correct, and changed exactly one thing: the cleanup hook stopped running. Nothing was thrown. The hook is declared in the sample as a quoted key and it was renamed anyway, because a quoted declaration key is still a declaration. There is no matching access site in the file to keep it consistent with, since the caller is the language itself.

The last arm is a reminder that awaiting is itself a property read. A pattern matching then broke the program immediately with TypeError: main(...)._0x1 is not a function. Every await and every promise chain in the file goes through a then lookup, so that one name is about as load-bearing as a name gets.

The arms that were supposed to be boring, and mostly were

Four of the eight arms exist to rule out failure modes rather than to find them, and it is worth saying what they showed, because a compatibility claim built only on the interesting cases is not worth much.

Delegation with yield* from one async generator into another preserved both the values and their nesting order: outer, inner, inner, outer, unchanged on every profile. Delegation is a plausible place for a rewrite to go wrong, because it involves one generator forwarding the protocol to another rather than producing values itself, and the forwarding has to preserve ordering in both directions.

A for await loop over an array of promises -- rather than over an async iterable -- also came back unchanged. This is a distinct code path in the language: the loop awaits each element of an ordinary synchronous iterable instead of driving an asynchronous one, and it is the shape most people actually write when they first reach for for await. It is also the shape most likely to be caught by the synchronous lowering, since the thing being iterated really is an array, and it was not.

Rejection handling was preserved. A promise that rejects inside the loop still threw into the surrounding try, with the original reason intact, on all five profiles. That matters because a rewrite that moved the awaiting around could easily convert a caught rejection into an unhandled one, which is the kind of change that shows up as a process-level warning rather than as a test failure.

The manual drive of the protocol -- calling next directly and reading value and done off what it resolves to -- also matched. That arm is the control for the member-renaming section below: it establishes that the protocol works normally before any pattern is applied to it, so a later failure can be attributed to the renaming rather than to the transform.

What to check, and what not to worry about

If you are not renaming members, this surface needs no attention. Eight arms, five profiles, no differences.

If you are, the names to keep out of your patterns are the protocol's: next, value, done, return, throw and then. That is an awkward list, because value and done are also two of the most natural field names in any application, and a pattern written to cover your own model can reach the protocol without mentioning it. This is the same hazard the iterator-helpers work ran into with the short conversion names, and the same mitigation applies: anchor patterns to names distinctive to your application rather than to bare common words.

The cleanup hook deserves a specific check because it has no local symptom. If any of your async iterables release a resource in return -- closing a cursor, releasing a connection, clearing a lock -- put a counter in that hook, break out of the loop early, and print the counter after protection. It is one number, and it is the only evidence you will get until the resource itself runs out.

One scope note, stated rather than implied. This sample drives async iteration in a single process with an in-memory source. It does not cover asynchronous sources that cross a transport, which bring their own contracts and their own key names; the article on streams and backpressure covers that surface separately.

Frequently asked questions

Does obfuscation break for await loops?

No, on the evidence of eight arms measured across five protection profiles. Ordering, early exit, cleanup, delegation with yield*, rejection handling and a manual drive of the protocol all behaved identically to the original. The interleaving trace in particular was unchanged, which is the result that rules out a drain-then-loop rewrite.

Is async iteration affected by the same defect as synchronous for-of?

No, and this is the useful contrast. On the default output target a synchronous for-of over a non-array iterable is lowered into a helper that drains the source into an array before the loop body runs, which changes ordering, defeats break and skips cleanup. Async iteration is not lowered that way on either target: the measured interleaving alternated normally, an early break left the generator having produced only the values consumed, and the cleanup hook ran.

Do I need the modern output target for async iteration?

No. That recommendation exists for the synchronous lowering described above. Async iteration measured clean on the default output target as well as the modern one, along with both gate profiles and the string-transform profile.

Does member renaming break async generators?

The generators themselves held up, and the boundary is what matters. An async generator's protocol is implemented inside the engine, where a member pattern cannot reach it, so the values, the interleaving and the early-exit count all stayed correct. A hand-written async iterable is ordinary code: a pattern matching next threw TypeError: undefined is not a function on it, and a pattern matching value and done made the program hang instead. Symbol.asyncIterator itself is a symbol and is never a rename site.

Why does my protected build hang instead of failing?

Check whether a member pattern matches done. A loop reads that property to decide whether iteration has finished; once it is renamed the read returns undefined, undefined is falsy, and the loop concludes there is more to come and asks again forever. Nothing is thrown because a condition that is never true is not an error. This was measured on a hand-written async iterable, where the process printed three correct lines and then ran until it was cut off.

Why did my async cleanup hook stop running after protection?

Check whether your member pattern matches return. A pattern matching only that name left every value, every count and every ordering correct and silently stopped the cleanup hook from running. Quoting the key in the declaration does not protect it, because a quoted declaration key is still a declaration and the caller is the language rather than any access site in your file.

Is it safe to rename a property called value or done?

Not if any object in your build implements the iteration protocol by hand, which includes any custom async iterable. Those two names are simultaneously among the most natural field names in an application and two of the four names the language reads to drive a loop, so a pattern aimed at your own model can reach the protocol without naming it deliberately.

What is the cheapest check for this surface?

Put a counter inside an async generator, break out of a for await loop after a couple of values, and print both the counter and the values. If the counter matches the number of values you consumed, ordering and laziness are intact. Add a counter to a return cleanup hook to cover the silent case, since that one changes nothing else.

Related reading