What a third-party script leaves behind

Removing a third-party script does not always remove it. If something wrapped a browser global after it, restoring the original deletes that tool's work.

Three nested glass wrapper rings surround a core, with the lifted outer ring glowing orange

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 installsHow it comes offSurvives a later wrapper
An event listenerRemoved by naming the exact handlerYes, on any page, in any order
A wrapper on an own propertyThe saved property descriptor is written backOnly while it is still outermost
A wrapper on an inherited methodThe own property is deleted so lookup falls through to the prototype againOnly while it is still outermost
A wrapper on an accessor propertyIt should never have been installedNot 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 teardownWhat the teardown did
The reference you capturedRestored what it found, and nothing of the script is left on it
What the tag installedNever uninstalled, and is still observing every call
Neither of thoseGave 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.

Common questions

What happens when two scripts wrap the same browser global?

The second wrapper calls the first, which calls the browser's own implementation, so both tools observe every call in installation order. The problem arrives at teardown, because whichever script removes itself first may no longer be the outermost wrapper and cannot restore the original without discarding the other's work.

Why should a script not restore the original global when it uninstalls?

Restoring means assigning the function the script captured at startup, which throws away every wrapper installed after it. A monitoring tool loaded later stops receiving calls and reports nothing, with no error raised and no way to detect the loss. A safe teardown restores only while the script is still outermost.

How can I tell whether a third-party script really removed itself?

Keep a reference to the global before any tag loads, and compare the property against that reference once the vendor's documented teardown has run. Identity is the only reading a wrapper cannot dress up, because one built as a proxy reports the original's own name and arity.

Does a leftover wrapper from an uninstalled script cost anything?

A correctly written one performs a single boolean check and forwards the call it was already going to forward, doing no work of its own. Treat that as bounded rather than as zero, and ask a vendor what its inert path does rather than what it claims the path costs.

How long does a leftover wrapper stay on my page?

As long as the document. The inert wrapper is held by whatever wrapped it, so it survives every teardown and cannot be reached from outside the page. A full navigation replaces the document and clears the chain, while a route change inside a single-page app does not.

The Notarus team

The people who build Notarus. We write up the decisions behind the product when the reasoning is worth more than the changelog line.

Keep reading

Reports with the evidence attached.

Start free, one script tag.

<script src="…/widget.js" data-key="pb_pub_…">