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

Connection cost of a third-party analytics origin Two request timelines. The third-party path spends 48 milliseconds on DNS, 92 on TLS, 60 on the request and 130 on script execution, finishing at 330 milliseconds. The first-party path reuses the existing connection, spends 18 milliseconds on the request and 18 on execution, finishing at 36 milliseconds. Most of the cost is the connection, not the code Third-party origin DNS 48 ms TLS handshake 92 ms fetch 60 ms execute 130 ms · 96 KB First party, existing connection fetch 18 ms run 18 ms nothing else needed 0 ms 180 ms 360 ms Cold connection, mid-tier Android on a fast 3G profile · the handshake alone outweighs the whole first-party path
The script's bytes are the smallest part of the bill. Removing the origin removes the DNS lookup and the handshake, which is where most of the 330 ms actually went.

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.

Fields to collect and fields to skip Two columns. The collect column lists template name, path, referrer hostname, country, bucketed viewport width, time on page and Core Web Vitals. The skip column lists full IP address, user identifier, cookie or local storage value, exact viewport, full referrer URL with query string, and mouse movement. A schema small enough to explain in one sentence Collect template name — what to group by path — which page referrer hostname only country from the edge viewport bucketed to 100 px time on page, rounded LCP, CLS, INP values Skip full IP address any user or session identifier cookies and local storage exact viewport and screen size referrer query strings pointer and scroll traces anything you cannot justify
The left column answers every question a content team asks in a weekly review. The right column is what turns an anonymous counter into a system with a compliance surface.

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 edgerequest.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:

SetupTransferBlocking timeOriginsINP
Hosted vendor analytics96 KB340 ms3210 ms
Vendor proxied through own domain96 KB330 ms2205 ms
First-party collector + web-vitals6 KB22 ms1130 ms
Blocking time and transfer by analytics setup Three grouped bars on a shared scale. Hosted vendor analytics uses 340 milliseconds of blocking time and 96 kilobytes. Proxying it saves 10 milliseconds. The first-party collector uses 22 milliseconds and 6 kilobytes, a 94 percent reduction in blocking time. Proxying helps a little; replacing helps a lot Hosted vendor 3 origins 340 ms · 96 KB Proxied vendor 2 origins 330 ms · 96 KB First-party 1 origin 22 ms · 6 KB Shared linear scale · blocking time on a mid-tier Android profile, median of 5 runs
Proxying removes the handshake but keeps the vendor's execution cost. Only replacing the script removes both, which is why the third bar is a different order of magnitude.

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.

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.