blockrate
v2.0.0
Published
Measure the actual block rate of third-party services caused by ad blockers and privacy tools.
Maintainers
Readme
blockrate
Know what your ad blockers are hiding from your analytics. A tiny, zero-dependency library that measures the per-provider block rate of the third-party tools your app depends on.
Reporters welcome. Pair this OSS client with
blockrate-serverfor a one-command self-hosted ingestion server, or use blockrate.app for a hosted dashboard with zero infrastructure. The library is identical either way, pick the reporter that fits.
Why
You're running experiments, but 20% of your users are invisible, blocked by uBlock Origin, Brave, Pi-hole, corporate firewalls. Existing "ad block detectors" only tell you a blocker exists. blockrate tells you which specific tools are blocked, so you can decide whether to reverse-proxy Optimizely, migrate PostHog server-side, or just accept the gap.
Quick start
nub add blockrateimport { BlockRate } from "blockrate";
const br = new BlockRate({
providers: ["optimizely", "posthog", "ga4"],
reporter: (result) => {
navigator.sendBeacon("/api/block-rate", JSON.stringify(result));
},
sampleRate: 0.1,
});
br.check();The client always posts to your own origin (/api/block-rate), not directly to blockrate.app or a self-hosted instance. See Why the reporter endpoint must be first-party for why this matters, and keep reading for the matching server route.
Why the reporter endpoint must be first-party
blockrate exists because ad blockers drop third-party analytics requests. For the measurement to be valid, the client must post to your own origin, never directly to blockrate.app, api.blockrate.app, or any dedicated analytics host. A server route on your own domain then forwards the payload to the ingest endpoint with your API key.
Two things break if you ignore this:
- The measurement itself fails.
blockrate.appis, by definition, an analytics domain, the exact shape of thing that lands on EasyPrivacy and other public blocklists. The moment it does, the tool measuring blocking only sees the blocking that isn't blocking blockrate itself: a reflexive, silent failure where "loaded" counts look normal because the "blocked" reports never arrived. - Your API key leaks. If the browser needs your
br_...key to authenticate the ingest request, the key is visible in DevTools, page source, and network inspectors to any visitor. There is no way to rotate or scope a key the browser already knows.
The forward option on createBlockRateHandler collapses the server-side forwarding into one line:
// app/api/block-rate/route.ts
import { createBlockRateHandler } from "blockrate/next";
export const POST = createBlockRateHandler({
forward: { apiKey: process.env.BLOCKRATE_API_KEY! },
});The API key stays on the server. The browser only knows about your /api/block-rate route, which is first-party and cannot be blocklisted independently of your app.
forward options
| Option | Default | Description |
| ----------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| apiKey | required | Your br_... key. Must be passed explicitly (the library never reads env vars itself). Malformed keys throw at handler construction time. |
| endpoint | https://blockrate.app/api | Override to point at staging, a different region, or a self-hosted blockrate-server instance. /ingest is appended automatically. |
| onError | optional | (err: ForwardError) => void. Called on network errors, timeouts, or non-2xx upstream responses. Without this, failures are silent. |
| timeoutMs | 5000 | Aborts the upstream fetch after this many ms. |
onError receives a discriminated union:
type ForwardError =
| { kind: "network"; cause: unknown }
| { kind: "upstream"; status: number; statusText: string; body: string };It never contains the API key, safe to log as-is. A common pattern:
forward: {
apiKey: process.env.BLOCKRATE_API_KEY!,
onError: (err) => console.error("[blockrate] upstream failed", err),
}Runtime requirement
createBlockRateHandler returns a (request: Request) => Promise<Response> function built on Web-standard Request/Response. It runs unmodified in Next.js App Router, SvelteKit, TanStack Start, Nuxt / Nitro, SolidStart, Bun, Deno, Cloudflare Workers, Vercel Edge, and Hono. For classic-Node (Express, Fastify) use @whatwg-node/server or a similar adapter to bridge between IncomingMessage and Request.
Pairing with onResult
forward composes with onResult, both fire in parallel on a valid payload. Failures are isolated (a thrown onResult does not prevent the forward, and vice versa), and the browser always receives 204 on a valid body.
export const POST = createBlockRateHandler({
forward: { apiKey: process.env.BLOCKRATE_API_KEY! },
onResult: (r) => myLogger.info({ event: "block_rate", ...r }),
});Built-in providers
optimizely, posthog, ga4, gtm, segment, hotjar, amplitude, mixpanel, meta-pixel, intercom. Each provider is checked first via a post-load global (a property the real bundle sets, not the queueing stub the loader snippet creates), then via a probe to its CDN. Stub-only globals are ignored, the loader snippet runs even when the network request to the CDN is blocked, so checking for the stub would silently misclassify a blocked install as "loaded".
Custom providers
import { BlockRate, createProvider } from "blockrate";
const mine = createProvider({
name: "my-analytics",
timeoutMs: 5000,
detect: async () => (window.myAnalytics ? "loaded" : "blocked"),
});
new BlockRate({ providers: [mine], reporter: console.log }).check();Detection deadlines
Custom provider objects, whether passed directly or through createProvider, have a
3000 ms deadline by default. Set Provider.timeoutMs to override it per provider.
The value must be an integer from 1 to 60000 ms; invalid values (including
zero, negative numbers, fractions, non-finite numbers, and non-numbers) throw a
RangeError when constructing BlockRate. Omit the field to use the default.
Each deadline starts when its detector is invoked, after the optional delay.
Expiry contributes a blocked result and a warning, allowing healthy providers to
be reported together with the timed-out provider. The deadline timer is cleared
on success, failure, or timeout. Late settlements cannot change the result or
cause another report; late rejections remain observed. Reported latency is capped
at 60000 ms, including when a delayed timer runs after its deadline.
Built-in names and exported built-in provider instances retain their existing
probe timeouts, with no extra deadline by default. An explicit timeoutMs on a
provider object bounds the whole detection, but does not change the timeout
arguments of probe(url, timeoutMs) or probeImage(url, timeoutMs). Set both
appropriately if a custom detector needs a longer probe.
A detector receives an optional AbortSignal that aborts when its deadline expires,
where AbortController is available. Pass it to work that supports cancellation:
const mine = createProvider({
name: "my-analytics",
timeoutMs: 5000,
detect: async (signal) => {
await fetch("https://cdn.example.com/analytics.js", { method: "HEAD", signal });
return "loaded";
},
});A deadline bounds waiting. Detectors must cooperate with the signal to cancel their work. The SDK cannot stop arbitrary network requests or synchronous work. Timers also need a responsive event loop and can be delayed by browser throttling. Existing detectors that take no argument remain compatible.
Error containment
The React useBlockRate hook and Next.js BlockRateScript component catch
initialization errors, warn via [blockrate] initialization failed:, and skip
measurement without unmounting the host application.
A detector that throws synchronously or rejects asynchronously contributes
blocked without preventing healthy providers from being reported. Detector
errors and deadline expiry use the existing warning channel:
[blockrate] provider "<name>" detect() threw:. This conservative fallback can
inflate the measured block rate, so inspect warnings before attributing failures
to an ad blocker.
The reporter is invoked once with the result. Synchronous throws and returned
promise rejections warn via [blockrate] reporter threw: without rejecting
check(). Reporter completion is never awaited, so a hanging promise cannot hold
up check(). Receiving a result from check() is not confirmation of delivery.
Detached async work that the reporter does not return must handle its own errors.
Logging failures are contained as well.
Options
| Option | Default | Description |
| ------------ | -------------- | -------------------------------------------------- |
| providers | required | Built-in names or custom Provider objects |
| reporter | required | Called once with a BlockRateResult |
| sampleRate | 1 | 0–1 fraction of sessions to check |
| delay | 3000 | ms to wait before probing (let scripts initialise) |
| sessionKey | __block_rate | sessionStorage dedup key |
React
import { useBlockRate } from "blockrate/react";
useBlockRate({
providers: ["optimizely", "posthog"],
reporter: (r) => fetch("/api/block-rate", { method: "POST", body: JSON.stringify(r) }),
});Next.js
// app/layout.tsx
import { BlockRateScript } from "blockrate/next";
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<BlockRateScript
providers={["optimizely", "posthog", "ga4"]}
endpoint="/api/block-rate"
sampleRate={0.1}
/>
</body>
</html>
);
}// app/api/block-rate/route.ts
import { createBlockRateHandler } from "blockrate/next";
export const POST = createBlockRateHandler({
forward: { apiKey: process.env.BLOCKRATE_API_KEY! },
});SvelteKit
// src/routes/api/block-rate/+server.ts
import { createBlockRateHandler } from "blockrate/sveltekit";
export const POST = createBlockRateHandler({
forward: { apiKey: process.env.BLOCKRATE_API_KEY! },
});TanStack Start
// src/routes/api/block-rate.ts
import { createFileRoute } from "@tanstack/react-router";
import { createBlockRateHandler } from "blockrate/tanstack-start";
const handler = createBlockRateHandler({
forward: { apiKey: process.env.BLOCKRATE_API_KEY! },
});
export const Route = createFileRoute("/api/block-rate")({
server: { handlers: { POST: ({ request }) => handler(request) } },
});Fixing a blocked provider: the first-party proxy
Measurement tells you which providers are blocked; the fix is serving them
first-party. blockrate/proxy ships a route handler that reverse-proxies a
provider through a subpath on your own domain, the most block-resistant
mount, because filter lists match hostnames and a path can't be blocked
without blocking your whole site. (A subdomain is simpler to operate but is a
separately-blockable hostname; if you go that route, use a real server-side
proxy, never a CNAME, which uBlock Origin uncloaks.)
v1 supports PostHog (officially documented reverse-proxy support):
// app/m/[...path]/route.ts, Next.js App Router; any Request/Response
// framework works the same way.
import { createBlockRateProxy } from "blockrate/proxy";
const proxy = createBlockRateProxy({ provider: "posthog", prefix: "/m" });
export const GET = proxy;
export const POST = proxy;
export const OPTIONS = proxy;// Point the SDK at the proxy (EU cloud: pass region: "eu" above).
posthog.init(token, {
api_host: `${window.location.origin}/m`,
ui_host: "https://us.posthog.com",
});Pick an unguessable mount segment (not /analytics or /track, path-token
rules already target those). The upstream host is pinned per region, so the
route is not an open proxy, and cookie/authorization headers are stripped
before forwarding so your first-party session credentials never reach the
vendor. Because blockrate's PostHog detector gates on the post-load global
(posthog.__loaded), your measured block rate falls as the proxy recovers
users, the before/after is visible in the same dashboard.
Self-hosted server
If you don't want to build ingestion yourself, run blockrate-server, a batteries-included Node server with SQLite storage, validation, rate limiting, multi-tenant API keys, and a one-page dashboard.
nubx blockrate-server
# [blockrate-server] listening on http://localhost:4318
# [blockrate-server] Bootstrapped default tenant. API key: br_xxxxxxxxxxxxxxxxxxxxxxxxSelf-hosters are first-party by definition, your server runs on infrastructure you own. The recommended integration is still a same-origin route that forwards to your blockrate-server instance, so the rationale above about ad blockers and key handling applies identically:
// app/api/block-rate/route.ts
import { createBlockRateHandler } from "blockrate/next";
export const POST = createBlockRateHandler({
forward: {
apiKey: process.env.BLOCK_RATE_API_KEY!,
endpoint: "https://br.example.com", // your self-hosted blockrate-server
},
});If you are genuinely running blockrate-server on the same origin as your app (reverse-proxied under /blockrate or similar), the older serverReporter pattern is also fine, nothing cross-origin happens:
import { BlockRate, serverReporter } from "blockrate";
new BlockRate({
providers: ["optimizely", "posthog", "ga4"],
service: "web-app",
reporter: serverReporter({
endpoint: "/blockrate", // same-origin reverse proxy
apiKey: "br_...", // still server-side-resolved; your proxy injects it
}),
}).check();Then open the dashboard, paste the API key, and you'll see per-provider block rates for every service reporting into that tenant.
One server can serve many services. The service field on each payload is stored per-row, so one organization can run a single blockrate-server for its entire fleet (web, mobile-web, admin, marketing site, etc.) and filter the dashboard by service.
Managing tenants:
blockrate-server tenant create web-app # prints a new API key
blockrate-server tenant list
blockrate-server tenant rotate web-app # rotates the key
blockrate-server tenant delete web-app # deletes tenant + all eventsEnvironment variables:
| Variable | Default | Description |
| --------------------------- | ---------------- | ---------------------------------- |
| PORT | 4318 | HTTP port |
| DB_PATH | ./blockrate.db | SQLite file path |
| BLOCK_RATE_BOOTSTRAP_KEY | random | Pin the bootstrap tenant's API key |
| BLOCK_RATE_BOOTSTRAP_NAME | default | Name of the bootstrap tenant |
Querying your data
Once you're collecting BlockRateResult payloads, the question you actually want answered is: for each provider, what fraction of sessions had it blocked?
SQL
Assuming you've flattened each provider into its own row (session_id, provider, status):
SELECT
provider,
COUNT(*) FILTER (WHERE status = 'blocked')::float / COUNT(*) AS block_rate,
COUNT(*) AS sessions
FROM block_rate_events
WHERE timestamp > now() - interval '7 days'
GROUP BY provider
ORDER BY block_rate DESC;PostHog
SELECT
properties.provider AS provider,
countIf(properties.status = 'blocked') / count() AS block_rate
FROM events
WHERE event = 'block_rate_check'
AND timestamp > now() - INTERVAL 7 DAY
GROUP BY provider
ORDER BY block_rate DESCAmplitude
Create a custom event block_rate_check and chart unique sessions segmented by provider where status = blocked, divided by total sessions.
How it works
- Post-load global check, fast, synchronous-ish. Each provider checks for a property that only the real bundle sets, never one the loader snippet creates (e.g.
posthog.__loaded,mixpanel.__loaded,analytics.initialized,google_tag_data). Stub globals likewindow.posthog,window.fbq, orwindow.amplitudeare deliberately ignored, they exist whether or not the CDN was reached. - CDN probe,
fetchthe provider's CDN URL withmode: "cors"(so blockers'nooptextredirects, which strip CORS headers, surface asTypeErrorrather than opaque success). Single attempt, honest fast-blocked vs timeout-blocked latency is more valuable than a retry that would pin every blocked-event latency to a backoff constant. - Image-tag probe (Meta only),
connect.facebook.netandfacebook.com/trdeliberately serve no CORS headers, so we use an<img>and listen foronerrorinstead. - Dedup (opt-in),
sessionDedup: truewrites a flag tosessionStorageso the check runs once per session. Off by default to keep the library consent-free; enable it for accurate session-level rates. - Report, your reporter is called once with all results.
FAQ
Won't this script get blocked too? No, it's bundled into your first-party code. Blocklists target third-party hostnames, not your app bundle. The same reasoning is exactly why the reporter endpoint must also be first-party: see Why the reporter endpoint must be first-party.
Is this ethical? Yes. You're measuring, not circumventing.
