What a blocker has to say yes to
“My blocker breaks the sites I use, and the only thing that reliably fixes a broken page is turning the blocker off.”
The intuitive model of a content blocker is a list of things it refuses. Requests go out, the blocker checks each one against its filters, and the ones that match never leave the browser.
That model explains the breakage people actually hit. A page asks for a script, the script is on a list, the request dies, and whatever the page was going to do with that script it now does not do. Sometimes what it was going to do was show you the article. The model also suggests the only two fixes anyone reaches for: allowlist the site, or turn the blocker off.
For some people the second fix becomes permanent. In one Hacker News thread in September, a commenter listing why they no longer run a blocker gave, second, that one had broken a web form while they were trying to buy something. Another said financial sites had been impacted by blockers, "which is why I stopped using them", and the next day named the real cost: "the cognitive load of discovering & remembering which sites break due to an adblocker".
Read a blocker's source, though, and refusing is not where the work is. Whole files of it exist to answer requests it has decided to cancel, with something other than the thing that was asked for. This post is about that half, and about what it tells you when a page breaks.
A catalogue of substitutes
uBlock Origin keeps a directory of substitute files, src/web_accessible_resources, and a
separate file that names what each one stands in for. At commit 01092d9, the tip of master
on 28 September 2026, src/js/redirect-resources.js exports a map of 47 entries, of which
31 carry an alias. The aliases are not internal names. They are the third-party URLs the
file is a substitute for:
[ 'google-analytics_analytics.js', {
alias: [
'google-analytics.com/analytics.js',
'googletagmanager_gtm.js',
'googletagmanager.com/gtm.js'
],
data: 'text',
So the unit of work is not "block analytics.js". It is "when something asks for
analytics.js, serve this file instead".
Nothing, in the right format
Several of the 47 are as close to nothing as a file can get. empty is zero bytes. noop.txt
is one byte, a newline. noop.js is an empty function that runs and returns:
(function() {
'use strict';
})();
noop.html is an eighty-two-byte document with an empty title and an empty body.
If the point were to stop the request, none of these would need to exist. Cancelling is free and already implemented. They exist because a cancelled request and an empty successful one are different events to the page: one hands the page an error to handle, the other hands it a result it can carry on from.
The same care shows up in the one place the substitute genuinely has no content. When a redirect
resource has empty data, redirect-engine.js picks the media type from the type of the
request, not from the resource:
const typeToMimeMap = new Map([
[ 'main_frame', 'text/html' ],
[ 'other', 'text/plain' ],
[ 'script', 'text/javascript' ],
[ 'stylesheet', 'text/css' ],
[ 'sub_frame', 'text/html' ],
[ 'xmlhttprequest', 'text/plain' ],
]);
Even nothing is given the type the page asked for: an empty answer to a stylesheet request
arrives as text/css, and an empty answer to a script as text/javascript.
noop-vast2.xml is the clearest case. It is twenty-eight bytes:
<VAST version="2.0"></VAST>
That is a well-formed ad response containing no ads. A network error is something a video player has to handle. This is an answer: the ad server replied, and it had nothing to show.
The parts that pretend to have worked
The substitutes that matter most are the ones that reimplement enough of the thing they replaced
that the page's own logic proceeds. google-analytics_analytics.js is 3,695 bytes and almost
none of it is a no-op. It rebuilds ga with create, getByName, getAll and remove, gives
the tracker prototype inert get, set and send, and then does three things whose only
audience is the page.
It declares itself loaded, on a line whose only comment is a link to the issue that caused it:
// https://github.com/uBlockOrigin/uAssets/issues/2107
ga.loaded = true;
That issue, from April 2018, reported that
Disqus comments on a cycling news site stopped loading with one of uBlock Origin's own filter lists
enabled. The first fix was the one everybody reaches for: an exception letting the real
analytics.js through on that domain. A commenter suggested that adding ga.loaded = true to
the stub "will probably help in general anyway", and the maintainer kept the issue open
"as a reminder to remove the exception for GA once 1.16.4 is released and widespread".
The allowlist was the stopgap. The line of code was the fix.
It hunts through the call the page made for a hitCallback and invokes it, because pages
sequence real work behind the tracker reporting success. And when the page has left a dataLayer
with a hide object on it, the stub calls dataLayer.hide.end() and then replaces that function
with one that does nothing. The names there belong to the page, not to the blocker: something on
the page installed an object with an end to call, and the blocker's job in that moment is to be
the thing that calls it. The comment heading that block links
an issue from 2017 titled "Some websites show a
blank page for a few seconds before the actual content", where the maintainer's first diagnosis
was "Page renders fast if allowing google-analytics.com".
That is a blank page, and the fix for it is not blocking less, it is answering better.
googletagservices_gpt.js does the same shape of work for the publisher tag: it sets
gpt.apiReady = true and gpt.pubadsReady = true, replaces googletag.cmd.push with a function
that runs the callback immediately, and then drains the queue the page had already filled.
And googlesyndication_adsbygoogle.js goes further than impersonating an API. For every
.adsbygoogle placeholder on the page it appends an iframe, names it aswift_1, aswift_2 and
so on, and once each iframe loads, sets attributes on its placeholder:
placeholder.dataset.adsbygoogleStatus = 'done';
placeholder.dataset.adStatus = 'filled';
It then keeps a MutationObserver watching for placeholders added later, and disconnects it
after twenty seconds. filled is not a description of anything. It is the answer a page gets
when it asks itself whether its ad slots worked.
nofab.js drops the pretence that any request is involved. Its alias is fuckadblock.js-3.2.0,
and it defines FuckAdBlock, BlockAdBlock, SniffAdBlock and their lowercase counterparts on
window. The two methods that decide the outcome are these:
Fab.prototype.onDetected = function() {
return this;
};
Fab.prototype.onNotDetected = function(a) {
a();
return this;
};
The callback for "no blocker here" is called. The callback for "blocker found" is never reached.
Answering is not free
Serving your own file in place of someone else's creates something new to detect, and the code
says so. redirect-engine.js refuses to point an xmlhttprequest at a web-accessible resource
at all, and the comment gives the reason:
// Prevent redirection to web accessible resources when the request is
// of type 'xmlhttprequest', because XMLHttpRequest.responseURL would
// cause leakage of extension id.
Those requests get a data: URI instead. Other requests for a packaged file get a URL under the
extension, with a secret= parameter attached when one is available. The concern the comment
names is identity: a response that gives away the extension's id tells the page which extension
it is talking to.
Two entries mark where substitution stops. One of the 47, google-ima-dai.js, is marked
requiresTrust: true, which src/js/trusted-tokens.js turns into a restriction: only filters
from trusted sources, meaning uBlock Origin's own lists and the user's own filters, may use it.
And click2load.html is not a substitute
at all: it is a placeholder with the blocked URL printed on it and a click target, which hands
the decision back to you.
What this means when a page breaks
If a blocker were only a list of refusals, a broken page would mean a missing asset, and the useful report would be "this site does not work". This code, and the two issues behind it, describe a different failure: a missing answer. Something on the page is waiting on a callback, a readiness flag, or a status attribute that the real script would have set, and the substitute for it either does not exist yet or does not go far enough.
That is why turning the blocker off fixes the page and tells you nothing about why. It restores every answer at once. The report that actually helps names the resource the page was waiting for, which is the difference between a filter list problem and a substitute that needs another line.
What was and was not checked here
Every code quotation above is from uBlock Origin's own source at commit 01092d9, read in full on
28 September 2026: src/js/redirect-engine.js, src/js/redirect-resources.js,
src/js/trusted-tokens.js, and the individual files under src/web_accessible_resources/. The
counts of 47 entries and 31 aliases were computed from that file rather than estimated. The two
issues are quoted from their GitHub threads, and the three Hacker News comments from Hacker News.
The boundary: this describes what the substitutes implement, read as code. Nothing here was run. No browser was loaded, no site was tested, and no measurement of how often any of this works was attempted. It is also not a claim about what Google Analytics, the Google Publisher Tag, AdSense or FuckAdBlock do today. The stubs are evidence of what those scripts were doing when each stub was written, and of what the pages around them depended on. The platform side, including how a redirect is expressed in each browser's blocking API, is deliberately left out.