← Back to blog

Why sendBeacon Breaks Cross-Origin Analytics (and How to Fix It)

navigator.sendBeacon silently drops requests when your analytics endpoint is cross-origin. Here's the root cause — credentials:include meets CORS wildcard — and the two-line fix that makes cross-origin analytics work reliably.

The Bug Nobody Notices Until It's Too Late

You install an analytics script. The dashboard shows zero pageviews. You refresh. Still zero. You check the console — no errors. You check the Network tab — the request goes out. You give up and assume it's a caching issue.

Two weeks later, you realize: nobody's data has been recorded. Not a single pageview. The script has been silently failing the whole time.

This is the sendBeacon CORS bug, and it affects many analytics scripts — including ones that are otherwise well-written. Here's exactly what's happening, why it's hard to spot, and how to fix it in two lines.

How Analytics Scripts Typically Work

A cookie-free analytics script like Beam follows a simple pattern: when a user visits a page, the script sends a small JSON payload (the path, referrer, screen width, etc.) to an analytics endpoint. That endpoint records the pageview.

The canonical way to send this payload on page unload — so it doesn't slow down navigation — is navigator.sendBeacon(). It was designed exactly for this use case: fire-and-forget, survives page unload, doesn't block the browser. Perfect on paper.

The typical implementation looks something like this:

// The naive approach — looks reasonable, has a hidden bug
const payload = JSON.stringify({ path: location.pathname, referrer: document.referrer })
navigator.sendBeacon('/api/collect', new Blob([payload], { type: 'application/json' }))

This works fine when the script and the analytics endpoint are on the same origin. But the moment they're cross-origin — script loaded from beam-privacy.com, analytics endpoint at beam-privacy.com/api/collect, page at yourapp.com — it silently fails.

The Root Cause: credentials:include Meets CORS Wildcard

Here's the mechanism. When you call sendBeacon with a Blob that specifies a content type, the browser treats the request as a credentialed cross-origin request. Credentialed means the browser sends cookies and other credentials along with the request.

The CORS specification has a hard rule: a credentialed cross-origin request cannot be satisfied by a wildcard Access-Control-Allow-Origin: * response header. The server must return the exact origin of the requesting page, not a wildcard.

Most analytics endpoints — especially those designed to accept data from any domain — return the wildcard:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type

That's perfectly valid for non-credentialed requests. But sendBeacon sends a credentialed request, so the browser silently drops the CORS preflight response as invalid. The request never reaches the server.

And here's why it's so hard to debug: sendBeacon failures are silent. There's no returned promise, no thrown error, no console warning. The browser logs a CORS error in the Network tab — but only if you're watching. Most analytics scripts fire on page unload, which clears the Network tab before you can see it. If you're not specifically looking for it with "Preserve log" enabled, you'll miss it entirely.

Why This Is Especially Common in Analytics

Analytics scripts are almost always cross-origin by design. The whole point of a hosted analytics service is that you load one script from a central domain and it tracks traffic across many different sites. Every one of those sites is a cross-origin relationship.

The combination of factors that creates this bug:

  1. Script is loaded cross-origin (from the analytics vendor's domain onto your domain)
  2. Script uses sendBeacon with a typed Blob (application/json triggers a CORS preflight)
  3. Server responds with Access-Control-Allow-Origin: * (perfectly reasonable for a public API)
  4. Failure is silent (no error thrown, no data in the dashboard)

Each of these four things is individually reasonable. Together, they produce a bug that can go undetected for days or weeks.

The Fix: Use fetch with keepalive and credentials:omit

The fix is straightforward. Instead of sendBeacon, use fetch with keepalive: true and credentials: 'omit':

// The broken sendBeacon approach
navigator.sendBeacon('/api/collect', new Blob([payload], { type: 'application/json' }))

// The fixed fetch approach
fetch('/api/collect', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: payload,
  keepalive: true,       // survives page unload, same as sendBeacon
  credentials: 'omit',  // no cookies sent — wildcard CORS works fine
})

Let's break down why this works:

  • keepalive: true — tells the browser to keep the request alive even if the page navigates away. This is the key property that makes sendBeacon useful for analytics, and fetch supports it directly.
  • credentials: 'omit' — explicitly tells the browser not to send cookies or other credentials. This makes the request non-credentialed, which means Access-Control-Allow-Origin: * is valid, and CORS succeeds.

For analytics, credentials: 'omit' is actually the correct behavior anyway. You're not authenticating — you're reporting anonymous pageview data. There's no reason to send cookies.

The fetch API also returns a promise, which means you can add error handling if you want to track failures — something sendBeacon cannot do.

How to Check If You Have This Bug

If your analytics shows unexpectedly low numbers — especially if users are reporting that data isn't recording — here's how to diagnose it:

  1. Open the browser's DevTools. Go to the Network tab.
  2. Check the "Preserve log" checkbox. This prevents the log from clearing when the page navigates.
  3. Visit a page that should record a pageview. Then navigate to another page (to trigger the unload).
  4. Filter the Network log by the analytics endpoint URL.
  5. Look for a request with a CORS error in the Status column — it may show as "(failed)" or display a red CORS error in the console.

If you see that, you have this bug. Replace the sendBeacon call with the fetch + keepalive + credentials:omit pattern above.

What Beam Does

We hit this bug ourselves while building Beam. After fixing it, we dug into why it happens and wrote this up so other analytics developers don't have to rediscover it the hard way.

Beam's tracking script uses fetch with keepalive: true and credentials: 'omit'. It works reliably cross-origin, doesn't require cookies, and is fully GDPR-compliant. If you want cookie-free analytics that actually records your pageviews, try Beam free — no credit card required.

Summary

  • sendBeacon with a typed Blob sends credentials by default, triggering a CORS preflight
  • Servers that return Access-Control-Allow-Origin: * cannot satisfy credentialed CORS requests — the request is silently dropped
  • Fix: use fetch({ keepalive: true, credentials: 'omit' }) instead
  • This pattern is safe for analytics — you're not authenticating, so omitting credentials is correct