Self-Hosting Analytics to Cut Third-Party Requests
A hosted analytics script costs more than its file size. It opens a connection to another origin, which means a DNS lookup, a TLS handshake and a fresh TCP round trip before the first byte arrives — 100 to 300 ms on a cold mobile network. It is usually gated behind a consent decision, so it also inherits that platform's latency. And it ships features most content teams never look at.
For a documentation or content site, the data that gets acted on is small: which pages are read, where readers came from, what device class they are on, and what the Core Web Vitals look like per template. All of that fits in a first-party script of a few kilobytes and an endpoint on your own domain. This guide builds it, measures the difference, and is the concrete alternative recommended in Third-Party Script Performance on Static Sites.
Prerequisites
- An edge function platform in front of your static site — Cloudflare Workers or Pages Functions, Netlify Functions, or Vercel Functions all work.
- A store for the events: an analytics engine dataset, a managed database, or an object store you batch into.
- Agreement with whoever owns privacy at your organisation on what you will and will not collect.
What the Extra Origin Actually Costs
The Recipe
1. The client script
Everything the collector needs fits comfortably under two kilobytes before compression:
// public/js/measure.js — served from your own domain, cached for a year
(function () {
const send = (type, extra) => {
const body = JSON.stringify({
type,
path: document.querySelector('meta[name="page-template"]')?.content || location.pathname,
url: location.pathname,
ref: document.referrer ? new URL(document.referrer).hostname : '',
vw: Math.round(window.innerWidth / 100) * 100, // bucketed, not exact
...extra,
});
navigator.sendBeacon('/e', body);
};
addEventListener('DOMContentLoaded', () => send('view'));
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
send('leave', { ms: Math.round(performance.now()) });
}
}, { once: true });
})();
Note what is missing: no cookie, no local storage, no device identifier, no exact viewport. Bucketing the viewport to the nearest hundred pixels keeps the device-class signal while removing a fingerprinting vector.
Load it with defer from your own origin, and give the file a hashed name so you can cache it immutably — the same policy the rest of your assets use, as described in CDN Caching Rules for SSGs.
2. The edge endpoint
// functions/e.js — Cloudflare Pages Function
export async function onRequestPost({ request, env }) {
const data = await request.json().catch(() => null);
if (!data || typeof data.type !== 'string') return new Response(null, { status: 400 });
const ua = request.headers.get('user-agent') || '';
if (/bot|crawler|spider|preview|headless/i.test(ua)) return new Response(null, { status: 204 });
await env.EVENTS.writeDataPoint({
blobs: [data.type, data.path, data.ref || '', request.cf?.country || ''],
doubles: [data.ms || 0, data.vw || 0],
indexes: [data.path],
});
return new Response(null, { status: 204 });
}
Return 204 with an empty body — the page is often already unloading and nothing consumes the response. Reject malformed payloads instead of storing them: a public endpoint receives junk within hours of going live.
3. Add vitals to the same beacon
The web-vitals library reports LCP, CLS and INP through the same channel, so one endpoint covers both traffic and performance. The collection details are in Measuring CLS in the Field With web-vitals.js; the important part here is that self-hosting the library removes the last third-party connection from the measurement path.
4. Query what you will actually read
SELECT blob2 AS template, count() AS views, avg(double1) / 1000 AS avg_seconds
FROM events
WHERE blob1 = 'view' AND timestamp > now() - INTERVAL 7 DAY
GROUP BY template ORDER BY views DESC LIMIT 20;
Resist rebuilding a product analytics suite. Two queries — top pages and top referrers — plus the vitals dashboard cover the decisions a content team makes weekly.
Decide the Schema Before You Ship It
The temptation with a collector you own is to log everything "just in case". Resist it: every extra field is a privacy question you now have to answer, a column you have to migrate, and a reason for the endpoint to fall inside consent scope. Decide up front which side of the line each field sits on.
Two of these deserve a note. Referrer hostname only keeps the "where did readers come from" answer while dropping query strings that frequently contain search terms or campaign identifiers you did not intend to store. And country from the edge — request.cf.country or the equivalent header — gives you geography without ever touching the IP address itself, because the platform resolves it before your code runs.
Write the schema down in the repository next to the endpoint. When someone proposes a new field six months from now, the discussion starts from a documented position rather than from whatever is easiest to add.
Measured Impact
Measured on a documentation site's article template, mid-tier Android profile, Chrome 4× CPU throttle, fast 3G, median of five runs:
| Setup | Transfer | Blocking time | Origins | INP |
|---|---|---|---|---|
| Hosted vendor analytics | 96 KB | 340 ms | 3 | 210 ms |
| Vendor proxied through own domain | 96 KB | 330 ms | 2 | 205 ms |
| First-party collector + web-vitals | 6 KB | 22 ms | 1 | 130 ms |
The INP row is the one that changed reader experience: 210 ms to 130 ms moved the template from "needs improvement" into the good band without touching a line of the site's own JavaScript.
Pitfalls & Rollback
- Rebuilding the vendor. Sessionisation, funnels and cohorts are where the complexity lives. If you genuinely need them, keep a vendor and gate it behind interaction instead.
- Logging full IP addresses. Truncate or drop at the edge. Raw addresses turn an anonymous counter into personal data and pull the whole endpoint into consent scope.
- Forgetting bots. Crawler traffic can be a third of raw page views on a public docs site. Filter at the edge, before the write.
- An unversioned client script. Cache it immutably with a hashed filename; a long-cached mutable file is how you end up with two versions of the schema in the same dataset.
- No back-pressure. A viral page can multiply event volume overnight. Confirm your store's write limits and add sampling before you need it, not after.
- Rollback: the collector is one script tag and one function. Remove the tag and collection stops; the vendor tag, if you kept it in a branch, can go back in the same deploy.
Conclusion
For a static content site, first-party measurement is both faster and simpler than the hosted alternative: one connection instead of three, six kilobytes instead of ninety-six, and a schema you control. Ship the small client script, receive beacons at the edge, put vitals through the same pipe, and keep the queries to the handful you actually read each week. Then apply the same scrutiny to whatever third parties remain, using the tiering and budgets in Third-Party Script Performance on Static Sites.
FAQ
Does self-hosting analytics avoid ad blockers?
Partly, and that is not the point. Blockers match on known script names and paths as well as domains, so a proxied vendor bundle is often still blocked. A genuinely first-party collector with your own naming is rarely matched, but treat any recovered traffic as a bonus rather than a reason to route around a reader's choice.
What do I lose compared with a hosted analytics product?
Sessionisation, funnels, cohort analysis and a polished dashboard. What you keep is page views, referrers, device class, and Core Web Vitals per template, which is what most content teams actually act on. Start there and add only what you find yourself missing.
Does a first-party collector need a cookie banner?
If it stores nothing on the device and collects no identifier, most regimes treat it as out of scope, but this depends on your jurisdiction and what you log. Keep the payload anonymous, avoid cookies and local storage, truncate IP addresses at the edge, and confirm the position with whoever owns privacy compliance.
How do I handle bots?
Filter at the edge on user agent and on requests that arrive without a matching page view, and drop anything from known crawler ranges. Bot traffic inflates page-view counts far more than it inflates vitals, so filtering matters most for the counting side.
Can I keep my existing vendor and still do this?
Yes, and it is a sensible transition. Run the first-party collector alongside the vendor for a month, compare the numbers, and only then decide whether the vendor is still earning its 96 kilobytes and its connection.
Related
- Parent: Third-Party Script Performance on Static Sites — budgets and tiers for everything else.
- Auditing Third-Party Scripts With Lighthouse — find what to replace next.
- Measuring CLS in the Field With web-vitals.js — the vitals half of the same pipeline.
- CDN Caching Rules for SSGs — cache the client script immutably.
- Setting Cache-Control Headers on Cloudflare Pages — the header syntax for the same host that runs the endpoint.