Removing a third-party script from your page does not always remove it. If anything wrapped the same browser global after that script did, the script has two ways out, and the polite-looking one silently uninstalls whatever the later tool installed. The other leaves a forwarding function behind.
Your page keeps a forwarding function after the script is gone
Two scripts on one page can want the same function. A monitoring tool assigns window.fetch a wrapper that records the request and calls through; a feedback widget loads later and does the same to the same property. Both work, and every request is seen twice.
The chain itself is fine. What causes trouble is that it has an order, and only its outermost link can be taken out cleanly. Whichever tool tears down first finds a value at window.fetch that it did not put there, and has to decide what to do about it. Most decide by not looking.
Restoring the original is how one script uninstalls another
The teardown that reads as careful is the one that saves the original at startup and puts it back at the end. On a page where nothing else touched that global it is exactly right, and the property comes back identical to what was there before.
On a page where something did touch it, that same line is the most destructive thing the script can do. Assigning the saved original discards every wrapper installed after it, including the one belonging to the error monitor somebody added last week. That monitor keeps running, with its handlers registered and its dashboard loading as usual, and simply never sees another request, because the function it wrapped is no longer the function the page calls.
No error is raised, on either side. The person living through this sees the first monitor go quiet on the day the second one arrived, and spends a while investigating the second one.
Some of what a script installs comes off cleanly and some does not
The difference is whether removal is addressed by identity or by position.
| What a script installs | How it comes off | Survives a later wrapper |
|---|---|---|
| An event listener | Removed by naming the exact handler | Yes, on any page, in any order |
| A wrapper on an own property | The saved property descriptor is written back | Only while it is still outermost |
| A wrapper on an inherited method | The own property is deleted so lookup falls through to the prototype again | Only while it is still outermost |
| A wrapper on an accessor property | It should never have been installed | Not applicable |
The listener row is the one worth copying. removeEventListener names the handler to remove, so the browser takes that one out and leaves everybody else’s in place. Wrappers have no equivalent, because a property holds one value and the only address anybody has is “whatever is there right now”. The last row is different again: a value cannot be read off an accessor without running its getter, so a script that works out what to wrap by reading the property has already caused a side effect before deciding to instrument it. Our registry reads descriptors rather than values for that reason, and declines a read-only property as well when it is inherited or closed to redefinition.
Our own widget wraps up to eight functions across four objects to produce the console and network log that lands on a ticket: four console methods, the page’s fetch, the XHR open method on its prototype, and the two history methods a single-page app navigates with. Uncaught errors, unhandled rejections, history pops, hash changes and clicks all arrive through listeners instead, and every one of those comes off on any page.
A script that cannot leave the chain can still get out of the way
There is a third option between restoring blindly and staying installed, and it costs one comparison.
Before touching anything, read the property back and compare it against the wrapper you installed. If they are the same object you are still outermost, and writing back the descriptor you captured is safe. If they are not, somebody wrapped you, and the correct move is to stay where you are and stop doing anything: lower a flag your wrapper reads on its first line, so every later call forwards straight to the function you wrapped. The later tool’s wrapper still runs, the browser’s implementation still runs, and an exception thrown inside it reaches the caller unchanged.
Teardown is also not a once-per-page event, which widens the window in which this happens. Our widget runs five capture modes and steps between them as the host page gets busy, dropping the informational console hooks and the click hook as soon as the page shows pressure. So the moment a wrapper is asked to come off usually arrives mid-session, long before anybody removes the tag.
While a hook is active its cost is charged per invocation against a hard ceiling of 2 ms, and one invocation above that is read as our own regression rather than as a busy page; it is the same governor described in the host isolation contract. An inert wrapper is charged nothing at all, because it returns before it reaches the meter.
You can read a vendor’s teardown from your own console
None of this needs the vendor’s cooperation to check, and the check is the comparison the teardown itself should be making.
Keep your own reference to the global before any tag loads. Reading window.fetch into a variable, early enough to be first on the page, is the whole apparatus. Once the vendor’s documented teardown has run, compare the property against that reference. Identity is the only reading that cannot be fooled, because it asks what the object is rather than how the object presents itself.
| What the property is after teardown | What the teardown did |
|---|---|
| The reference you captured | Restored what it found, and nothing of the script is left on it |
| What the tag installed | Never uninstalled, and is still observing every call |
| Neither of those | Gave way to something that wrapped it, and now forwards from inside the chain |
If nothing captured a reference in time, toString() is a fallback rather than a test: a wrapper written as an ordinary function returns its own body instead of the original source or [native code]. Read that as evidence and stop there. A wrapper built as a Proxy reports the original’s name and arity and still answers [native code], so it survives every reading a page can take after the fact.
Our widget reports the condition rather than leaving you to detect it. Its public performance state carries a counter that goes up when a teardown found something other than its own wrapper in place, and that is one of only two conditions its health summary observes for itself. The behaviour is pinned on Chromium, Firefox and WebKit by a test in which a fixture wraps the page’s fetch after the widget has started, confirms the page’s own request still completes through the doubled chain, then destroys the widget and asserts the counter reads one.
The residue outlives every teardown but not the document
An inert wrapper is still an object and it is still referenced, because whatever wrapped it holds it. No later teardown removes it either, since every careful teardown after the first finds a property it did not install and leaves it alone. A full navigation replaces the document and the chain goes with it; a route change inside a single-page app does not.
One other thing ends the chain, and it is the move this whole argument is against. A script that assigns a pristine function over the property clears every wrapper at once, ours included. The residue goes, and so does whatever was still doing useful work.
Our own answer has a hole in it, and naming it is cheaper than pretending otherwise. Where another script wrapped one of the globals we instrument after we started, our function stays in that page’s call chain until the document is replaced, and we cannot remove it from outside. We do not publish a per-call cost for it either, because two of the three engines we ship against truncate their timer to a millisecond by default, and a figure taken below the clock’s resolution on the third is not a measurement of the other two. What we do instead is name the condition, count it, and put the count where you can read it on your own page.