How to Measure Your Own Site's Ad Block Rate
Add a small script that checks whether an ad-like bait element gets hidden and whether the AdSense script URL can load, then send the result to an endpoint on your own domain. Count blocked versus clear sessions for at least 1,000 visits. Do not rely on GA4 alone, because many ad blockers also block GA4, so it undercounts.
The "about 30 percent of people block ads" figure gets repeated everywhere, but it tells you almost nothing about your own site. A tech tutorial read on desktop Firefox can lose half its ad impressions. A recipe site read mostly on phones might lose under 10 percent. If you are deciding whether AdSense is worth it, or why your RPM looks low, you need your own number, and you can get it in an afternoon.
The catch most people miss: if you measure ad blocking with Google Analytics, many of the visitors you are trying to count never reach Google Analytics at all. Below are two methods, one quick and one accurate, and how to read what they tell you.
Why GA4 alone undercounts ad blockers
Ad blockers work from filter lists. The main ad list blocks ad scripts such as pagead2.googlesyndication.com/pagead/js/adsbygoogle.js. Privacy lists, which several popular blockers switch on by default, also block analytics tags such as googletagmanager.com/gtag/js. Brave's built in shields block both.
So a visitor with a strict setup blocks your ads and your GA4 tag at the same time. If your detection code reports through GA4, that visitor sends nothing, and your measured block rate comes out lower than reality. GA4 is still useful for a rough first read, but treat its number as a floor, not the real rate.
The fix is to report the result to an endpoint on your own domain. Filter lists rarely block a first party path with a neutral name, so almost every visitor who runs JavaScript gets counted, blocker or not.
The detection snippet
A reliable check tests two layers, because blockers use both:
- Cosmetic filtering: hiding page elements that look like ads. You test this with a "bait" element that carries ad-like class names, then check whether it got hidden.
- Network filtering: stopping requests to ad servers. You test this by requesting the AdSense script URL and seeing whether the request fails.
Paste this just before </body> on your page template. It runs once per browser session so a reader who views 8 pages counts once per visit, not 8 times.
<script>
(function () {
try { if (sessionStorage.getItem('pxc')) return; sessionStorage.setItem('pxc', '1'); } catch (e) {}
var bait = document.createElement('div');
bait.className = 'adsbox ad-banner textads banner-ads';
bait.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;';
bait.innerHTML = ' ';
document.body.appendChild(bait);
var netBlocked = null;
fetch('https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js',
{ method: 'HEAD', mode: 'no-cors', cache: 'no-store' })
.then(function () { netBlocked = false; })
.catch(function () { netBlocked = true; });
setTimeout(function () {
var cssBlocked = bait.offsetHeight === 0 ||
getComputedStyle(bait).display === 'none';
bait.remove();
var status = (cssBlocked || netBlocked === true) ? 'blocked' : 'clear';
var layer = cssBlocked && netBlocked ? 'both' : cssBlocked ? 'css' : netBlocked ? 'network' : 'none';
var q = '/api/px?s=' + status + '&l=' + layer;
if (navigator.sendBeacon) navigator.sendBeacon(q); else fetch(q, { method: 'POST', keepalive: true });
}, 2500);
})();
</script>
Two details matter here. First, the endpoint is called /api/px, not /adblock-check. Some filter lists block URLs containing words like "adblock" or "ads", which would quietly drop exactly the visitors you want to count. Second, the 2.5 second wait gives a slow mobile connection time to finish the network test. If netBlocked is still unknown at that point, the snippet does not count it as blocked, which keeps the number conservative.
Count it on Cloudflare Pages without a database
If your site is on Cloudflare Pages, a Pages Function plus Workers Analytics Engine gives you a counter with no database to manage. Add a binding to the wrangler.toml in your site folder:
[[analytics_engine_datasets]]
binding = "PX"
dataset = "adblock_checks"
Then create functions/api/px.js:
export async function onRequestPost({ request, env }) {
const url = new URL(request.url);
const status = url.searchParams.get('s') === 'blocked' ? 'blocked' : 'clear';
const layer = (url.searchParams.get('l') || 'none').slice(0, 10);
const ua = request.headers.get('user-agent') || '';
const device = /Mobi|Android|iPhone/i.test(ua) ? 'mobile' : 'desktop';
const country = (request.cf && request.cf.country) || 'XX';
env.PX.writeDataPoint({ blobs: [status, device, country, layer], doubles: [1], indexes: [status] });
return new Response(null, { status: 204 });
}
Deploy as usual. Each Function call counts against your Workers request allowance (the free plan includes 100,000 requests per day across Workers and Pages Functions), and because the snippet fires once per session, even a site with tens of thousands of daily visits stays well inside it.
After a few days, query the results with the SQL API. Create an API token with the Account Analytics: Read permission, then run:
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/analytics_engine/sql" \
-H "Authorization: Bearer $API_TOKEN" \
-d "SELECT blob2 AS device, blob1 AS status, SUM(_sample_interval) AS sessions
FROM adblock_checks
WHERE timestamp > NOW() - INTERVAL '7' DAY
GROUP BY device, status
ORDER BY device"
The SUM(_sample_interval) part matters: Analytics Engine can sample at high volume, and that column scales each row back up to a real count. Swap blob2 for blob3 to split by country, or blob4 to see which blocking layer is doing the work.
Not on Cloudflare? Any server you control works the same way: log the request path from your access log and count lines with s=blocked versus s=clear. For example, grep -c "/api/px?s=blocked" access.log.
The quick GA4 version, and its limit
If you only want a rough number today, replace the beacon line in the snippet with a GA4 event:
if (window.gtag) gtag('event', 'adblock_check', { adblock_status: status, adblock_layer: layer });
Then register the parameter so you can report on it: in GA4 go to Admin > Data display > Custom definitions > Create custom dimension, set the scope to Event, and enter adblock_status as the event parameter. Custom dimensions only collect data from the moment you create them, so set this up before you expect to read results. After a day or two, build a free form exploration with the adblock_check event, the new dimension as rows, and Event count as the value.
Remember the limit from earlier. Visitors whose blocker also stops GA4 never send this event, so this version measures only the "polite" blockers. If GA4 says 9 percent and your first party counter says 24 percent, the gap is visitors who block both ads and analytics, which also means your GA4 traffic numbers are short by a similar share.
Reading your number and what to do with it
Wait for enough sessions. With 1,000 checks and a true rate around 25 percent, your result can still be off by roughly 3 points either way. Wait for at least 1,000 sessions before deciding anything, and split by device only once each group has a few hundred.
Put a price on it. Multiply monthly pageviews by your block rate, then by your RPM on the pages that do show ads. A site with 30,000 monthly pageviews, a 22 percent block rate and an $8 RPM loses about 30,000 x 0.22 x 8 / 1,000 = $52.80 a month. That tells you how much effort recovery is worth.
Check it against AdSense. In AdSense, open Reports and compare its page views for the same 7 days against your own server side pageview count. A large gap that matches your measured rate confirms the reading. A much larger gap points to a different problem, such as ad code missing from some templates.
Decide whether to act. Under about 10 percent, it is rarely worth any work; spend the time on content and traffic. Above 20 to 25 percent, consider the Ad blocking recovery message under Privacy & messaging in AdSense, which asks blocking visitors to allow ads. Also consider income that blockers do not touch, such as affiliate links inside the article text. Do not build walls or nag screens that hurt the reading experience, because a reader who leaves is worth less than one who stays with ads off.
While you are in there, it is worth confirming that the rest of your setup, including ads.txt, your analytics tag and the pages Google can actually reach, is in order. The free Idea to Traffic site checker runs 30 of those structural checks on any URL.
Where this question came from
- How are you actually measuring your site's ad-block rate? (r/juststart)
- Measure ad blocker impact with server-side GTM (Simo Ahava)
- Pages Functions Wrangler configuration: Analytics Engine datasets (Cloudflare)
Questions about measuring ad blocking
Why is my ad block rate in GA4 lower than expected?
Many blockers use privacy filter lists that block the GA4 tag as well as ads. Those visitors never send your detection event, so GA4 only counts blockers that leave analytics alone. Report the check to an endpoint on your own domain to see the full rate.
How many visits do I need before the number means anything?
Aim for at least 1,000 sessions. At that size a rate around 25 percent is accurate to roughly 3 points either way. Splitting by device or country needs a few hundred sessions in each group.
Will the detection script slow my pages down?
No. It runs after the page has loaded, makes one small HEAD request and one beacon, and only once per browser session.
What block rate is normal?
It varies by audience. Desktop heavy technical audiences often block far more than mobile heavy general audiences, which is exactly why the industry average is a poor guide for your own site.