Compatibility
Published
A cancellation primitive has an unusual property: when it fails, the observable result is that nothing happens. Work that should have stopped carries on, a listener that should have been removed stays registered, a request that should have been abandoned completes. There is no error, because not cancelling is what the code does when there is nothing to cancel. That makes AbortSignal worth measuring carefully rather than assuming.
What was measured
The sample covers the full surface of an ordinary cancellation: an AbortController, a read of its signal, the aborted flag before and after, an abort carrying a reason, a listener registered for the abort event, a throwIfAborted guard, the signal key passed into a platform call, and a timeout signal produced by AbortSignal.timeout. It ends with a plain settings object the file owns end to end, as a control.
AbortController and AbortSignal are real platform objects in current Node, so the code reading the signal key out of an options object is genuinely outside the file. This is the same discipline the rest of this series follows: if the reader of a contract lives in the sample, renaming both halves together proves nothing.
Across five protection configurations -- the default target, the modern target, both gate profiles and the string-transform profile -- every line matched. The flag was false before the abort and true after. The reason came back with its message intact. The listener fired exactly once. throwIfAborted threw, and threw the reason rather than a generic error. The signal passed into the platform call arrived already aborted.
So the primitive itself is not the problem. As with the rest of this series, the whole of the interesting result is in the member-renaming column, and here it splits cleanly into one loud failure and one very quiet one.
The quiet failure: a guard that always says no
With a member pattern matching aborted and reason, the protected file ran to completion and printed a full set of results. Every one of them was wrong in the same direction.
Measured: aborted read undefined before the abort, undefined after the abort, and undefined on the signal that had been handed to a platform call. The reason read undefined. The timeout signal read undefined. Nothing threw, nothing was logged, and the process exited normally.
Consider what that does to the code this primitive exists for. The standard shape of a cancellable loop is a check at the top of each iteration: if the signal is aborted, stop. In the protected build that check reads undefined, which is falsy, so it never stops. A long-running task keeps running after the user has navigated away. A polling loop keeps polling after the component has unmounted. An upload keeps uploading after cancel was pressed.
The reason field degrades the same way and takes the diagnostic with it. Code that logs why something was cancelled now logs undefined, so the one artefact that would explain the behaviour is blank. This is the same silent shape as a renamed error message: the machinery runs, the reporting is empty, and every check you have stays green.
One guard survives, and it is the one people skip
There is a genuinely useful detail in the same measurement. throwIfAborted still threw, with the correct message, in the arm where every aborted read was returning undefined.
The reason is straightforward once stated: throwIfAborted is a method the platform implements, and it consults its own internal state rather than the property your code reads. Renaming the property does not change what the platform knows. Renaming the method is a different matter, and does fail loudly -- a pattern matching abort, throwIfAborted and addEventListener produced TypeError: sig._0x1 is not a function on the first call.
This makes throwIfAborted the more robust of the two idioms under member renaming, which is the opposite of the usual advice to prefer a cheap property read over a method call that throws. Reading the flag is the pattern most code uses, and it is the pattern that fails silently. Calling the guard is less common, and it either works correctly or stops the process.
That is worth knowing but it is not a recommendation to restructure your code around an obfuscator's behaviour. The correct fix remains scoping the member pattern. The point is diagnostic: if cancellation seems to be ignored in a protected build and a throwIfAborted call still behaves, the property reads are what were renamed.
The signal key, and a wiring failure with no symptom of its own
The other arm is the signal key itself, and it is worth separating two effects that happen together. A pattern matching signal threw immediately -- TypeError: Cannot read properties of undefined (reading 'aborted') -- because ac.signal became ac._0x1, which does not exist.
That throw is what you see. Underneath it, something quieter had already happened. The options object handed to the platform call was emitted as {_0x1:sig, method:'GET'}, which means the signal was never wired in at all. Had the sample not also read the flag back, there would have been no exception -- just a request that could not be cancelled, built by code that plainly passes a signal.
That is the failure mode to have in mind for real code, because real code usually does pass the signal and then never reads it back. A renamed signal key produces a perfectly valid request with no cancellation attached. Calling abort later does exactly nothing to it, and the only observable difference is that a request you expected to be dropped completes and delivers its response.
The control arm, a settings object of the sample's own with retries and label, measured identical on both targets. Renaming a name that never leaves the file is invisible, which is the whole basis of the mitigation.
An arm that proved nothing, and why it is recorded here
One measurement in this sample deserves to be reported as inconclusive rather than as a pass, because reading it as a pass would be wrong. The abort listener is registered with { once: true }, and a pattern matching once measured SAME.
That result carries no information. An AbortSignal fires its abort event at most once by specification, so a listener registered on it fires exactly once whether or not the once option is honoured. The arm cannot distinguish a working option from an ignored one.
The companion measurement on ordinary event targets settles it, and the answer is that the option is not safe: with the same key renamed, a listener registered with { once: true } fired on every dispatch. That result and the mechanism behind it are covered in the article on listener options linked below.
The habit this represents is worth more than the specific finding. A sample can pass because the thing being tested is working, or because the sample cannot tell the difference. Reading the original output rather than the verdict is how you tell them apart, and it is the check that has caught the most mistakes across this series.
How to check your own build
Two assertions cover the practical risk. Create a controller, abort it, and assert that the flag reads true rather than merely truthy -- a strict comparison against true catches the undefined case that a plain if does not. Then assert that a value passed as a reason comes back with its message.
For the wiring, the check is different in kind, because there is nothing local to assert on. Log the options object you hand to the platform call, serialised as JSON, and confirm the key is still spelled signal. A generated name there means every cancellation in that module is inert regardless of what the rest of the code does.
It is worth doing this in the module that owns your HTTP client rather than in a test helper, because the member pattern applies per build and the helper may not be protected the same way. The failure is per-file in the sense that it is per-name, and the names in a transport module are exactly the ones most likely to look generic enough to match a broad pattern.
The mitigation is unchanged and it is complete: anchor the member pattern to names your own application owns. signal, aborted and reason are not among them -- they are the platform's, and the platform is reading them in between your write and your read.
Frequently asked questions
Does obfuscation break AbortController?
Not on its own. A controller, its signal, the aborted flag before and after, an abort reason, an abort listener, a throwIfAborted guard and a timeout signal all behaved identically across five protection configurations, including both target versions and the string-transform profile.
Why is my code ignoring cancellation after protection?
Most likely because member renaming reached the aborted property. Measured, every read of signal.aborted returned undefined with nothing thrown, and undefined is falsy, so the standard guard of stopping when the signal is aborted never fires. Work continues after the user cancels and no error is produced.
Does throwIfAborted still work if the aborted flag is renamed?
Yes, and that contrast is the most useful diagnostic here. In the same measurement where every aborted read returned undefined, throwIfAborted still threw the correct reason, because it consults the platform's own state rather than a property your code reads. If that guard behaves while the flag reads undefined, the property names were renamed.
What happens if the signal key in an options object is renamed?
The signal is never wired in. The emitted options object becomes {_0x1: sig, ...}, the platform sees no signal property, and it builds a perfectly valid call with no cancellation attached. Calling abort afterwards has no effect on it, and unless your code also reads the signal back there is no exception to tell you.
Is a renamed abort method easier or harder to spot than a renamed flag?
Much easier. Renaming abort, throwIfAborted or addEventListener produced an immediate TypeError on the first call and stopped the process. It is the property reads that fail silently, which is why a cancellation bug in a protected build is usually a renamed flag rather than a renamed method.
How do I verify cancellation survived my protection settings?
Abort a controller and assert the flag equals true with a strict comparison rather than a truthiness check, then assert the reason message comes back. Separately, log the options object you pass to any platform call as JSON and confirm the key is still spelled signal, because a renamed key leaves cancellation inert with nothing local to observe.
Related reading