What survives
Published
Asynchronous code fails in a distinctive way: it produces the right values in the wrong order, and the tests still pass because they assert on values. So the question worth asking about protection is not whether an await returns the right thing, but whether everything still happens when it should.
Ordering is the thing to measure
It is easy to write a test that an async transform passes while being subtly wrong. Await the function, assert the resolved value, done. That test passes even if the function body started running a tick too early, even if a microtask was hoisted past a synchronous statement, and even if a rejection was caught by the wrong handler.
The measurement here therefore records a SEQUENCE rather than a value. Every interesting point in the program pushes a label onto an array, and the joined array is compared between the original and the protected build. If anything moves, the string changes.
What was measured
The program exercised the four things that most commonly shift. A synchronous statement after the async call, to confirm that calling an async function runs its body up to the first await and then returns to the caller. A Promise.resolve().then(...) queued before that, to place a microtask in a known position. An await on a timer-backed promise, to force a real suspension. A try/catch wrapped around await Promise.reject(...). And a Promise.all over two pending promises.
The original produced sync-end | sync-start | microtask | after-await-A | caught-boom | all-X+Y | resolved-done. The ordering is worth reading: the synchronous tail of the module ran before the async function’s own first statement, because run() is invoked at the end, and the queued microtask ran before any awaited timer resolved.
The protected build produced the same string, character for character, on the ES5 target and on the modern target.
Rejection handling is the part worth calling out
await on a rejected promise has to throw into the surrounding try, not reject the enclosing async function and not become an unhandled rejection. That is a genuinely easy thing to get wrong when rewriting, because the rewrite has to route an asynchronous failure into a synchronous-looking catch block that may be several statements away.
In the measured run the rejection was caught by its own catch, the handler ran in the right position in the sequence, and execution continued into the following Promise.all. Nothing escaped as an unhandled rejection, which would have been visible as a node warning on stderr.
Scope, honestly stated
This covers async functions and promise ordering. It does not cover asynchronous ITERATION, which is a different surface: for await, async generators and anything that pulls values lazily from an iterator go through separate machinery, and nothing in the measurement above says anything about them.
That distinction is not hedging for its own sake. Lazy iteration is exactly the kind of construct where a rewrite can preserve every value while changing WHEN each one is produced, and a sequence test over async functions would not notice. If your code depends on it, test that specifically rather than reading a clean result here as covering it.
For everything this article does cover, the practical advice is the usual one and it is cheap: your existing async tests, run against a protected build, assert the same orderings that were measured here against your own code rather than a sample.
Frequently asked questions
Does async/await still work after obfuscation?
Yes. Measured by executing the protected output and comparing a recorded sequence of events, an async function produced the identical ordering before and after protection on both the ES5 and the modern target.
Is microtask ordering preserved?
Yes. A microtask queued with Promise.resolve().then before any await resolved ran in exactly the same position in the sequence in both builds.
Does calling an async function still run its body synchronously up to the first await?
Yes. The measured sequence shows the caller's following statement running after the async function's first statement and before its first resumption, which is the specified behaviour.
Is a rejected promise still caught by the surrounding try/catch?
Yes. Awaiting a rejected promise inside a try block ran the matching catch in the right position, and produced no unhandled rejection warning.
Does Promise.all still resolve correctly?
Yes. A Promise.all over two pending timer-backed promises resolved with both values in order, in the same position in the sequence as the unprotected build.
Does this cover for await and async generators?
No. Asynchronous iteration goes through different machinery and was not measured here. If your code pulls values lazily from an iterator, test that case specifically rather than treating this result as covering it.
Related reading