Compatibility
Published
Concurrency bugs are the ones that survive testing and appear in production, so a build step that touched promise scheduling would be genuinely dangerous. We built a sample covering all four combinators, thenable adoption, rejection paths, AbortController and microtask ordering, then diffed it against protected copies on five configurations. Every line matched. The hazard in this area turned out to be somewhere else entirely, and it is worth knowing about before you enable member renaming.
The sample, and why the timings are fixed
Every asynchronous step in the sample resolves on an explicit delay, so completion order is decided by the code rather than by machine speed. That makes the output deterministic and a diff meaningful. A sample whose ordering depends on load would produce different output on consecutive unprotected runs and could prove nothing.
It covers Promise.all with a mixture of promises and plain values, all rejecting on the first failure, allSettled with a mix of outcomes, race settling on the first result and on the first rejection, any returning the first success and rejecting with an AggregateError when everything fails, thenable adoption, a catch that recovers and resumes the success path, finally passing a value through, an AbortController with a reason and a listener, and the ordering of synchronous code against microtasks against a timeout.
All five configurations matched the original line for line: ES5, modern, both identifier-renaming presets, and the string table.
Ordering guarantees survived intact
The result order of Promise.all follows the input array, not the completion order. Our sample resolves the second element faster than the first and still received ["a","b",3,"d"] in both builds. Plain values and already-resolved promises are still adopted into the right slots.
allSettled returned its records in input order with the correct status, value and reason fields, so a rejection in the middle of a batch is still reported rather than thrown. race settled on the fastest entry, and settled to a rejection when the fastest entry rejected, which is the behaviour people forget when they use it as a timeout.
any returned the first success while ignoring two rejections, and when every input rejected it produced an AggregateError whose errors array held both messages in order. Both the error type and the array contents were identical after protection.
Microtask ordering is unchanged, which is the strongest single result here. The sample records the sequence of synchronous code, two chained microtasks and a zero-delay timeout, and got sync>micro1>micro2>timeout in every configuration. If protection had introduced a wrapper that deferred anything, that string would have moved.
AbortController and thenables
AbortController behaved identically: the abort listener fired, signal.aborted read true, and the reason passed to abort() arrived intact at the rejection handler. Cancellation plumbing is ordinary object and event machinery, and it is preserved as such.
Thenable adoption also survived. A plain object with a then method is adopted by Promise.resolve exactly as a real promise is, and returned the same value after protection. This matters if you interoperate with an older promise library or a hand-rolled deferred.
The recovery path is unchanged too. A rejected promise caught by catch, whose handler returns a value, resumes the success path with that value, and finally passes the settlement through without altering it. Our sample asserts both and both matched.
The real hazard: renaming a name the language owns
The compatibility answer is boring, so here is the part that is not. Member renaming applied to the wrong names breaks promises hard, and we measured two cases because both names are plausible in application code.
Renaming then destroys the machinery outright. With a pattern matching then, the sample died on its first line with TypeError: Promise.all(...)._0x1 is not a function. then is not a method name you happen to use; it is the protocol the language uses to recognise a promise at all. Nothing that awaits or chains will work once it is renamed.
The subtler case is worse, because status, value, reason and errors are ordinary domain words that appear in application objects all the time. With a pattern matching those four, the allSettled records became unreadable: the code that inspects settled.reason.message threw Cannot read properties of undefined, because the record the language handed back still has reason and the protected code is asking for something else. Nothing warns you; the first symptom is a crash in error-handling code, which is the code least likely to be covered by tests.
The rule that has now held across every area we have measured: renaming is safe when a name is only reached by code you control, and unsafe when it is part of a contract with the language, a library, or your own string-based reflection. then, status, value, reason and errors are all contracts. Keep them out of MemberRegexp.
What to do with this
Enable protection and run your existing asynchronous tests against the protected bundle. Combinator semantics, rejection paths and microtask ordering were identical in every configuration we measured, so a passing suite before protection is good evidence of a passing suite after.
If you enable member renaming, audit the pattern against this list before shipping: then, catch, finally, status, value, reason and errors. An anchored pattern that names only your own fields, such as ^(orderTotal|lineItems)$, avoids the whole category.
If protected async code fails and renaming is on, turn renaming off first and re-test. That single step separates a renaming problem from everything else, and in this area it is by far the most likely cause.
For the async and await syntax layer rather than the combinators, the companion measurement is in does obfuscation break async and await.
Frequently asked questions
Does obfuscation change Promise.all result order?
No. Promise.all returns results in input order regardless of which settles first, and our sample resolves the second element faster than the first specifically to test that. It returned the same array in all five configurations, covering both the ES5 and modern targets, two identifier-renaming presets and the string table.
Can protection change microtask or event loop ordering?
Not in anything we could measure. The sample records the order of synchronous code, two chained microtasks and a zero-delay timeout, and produced sync>micro1>micro2>timeout identically before and after protection on every configuration. A wrapper that deferred work would have changed that string.
Does Promise.allSettled still report rejections correctly?
Yes, with the same status, value and reason fields in input order. The one way to break it is member renaming applied to those field names, which makes the records unreadable to your own inspection code. We measured that failure and it surfaces as a TypeError inside error-handling code.
Is AbortController affected by obfuscation?
No. The abort listener fired, signal.aborted read true, and the reason passed to abort() arrived intact at the rejection handler, all identical after protection. Cancellation is ordinary object and event machinery from the transform's point of view.
Why did my promises stop working after enabling member renaming?
Most likely because the pattern matched then. That is the protocol name the language uses to recognise a promise, so renaming it breaks every chain and await in the bundle; we measured it and the failure is an immediate TypeError. Anchor MemberRegexp to your own field names and keep then, catch and finally out of it.
Does AggregateError from Promise.any survive protection?
Yes. When every input rejected, the sample received an AggregateError whose errors array held both messages in the original order, and the error type and contents were identical after protection. Note that errors is a language-owned property name, so member renaming should not be pointed at it.
Related reading