Fix CORS Errors on Analytics Beacons: 2026 Guide
EngineeringAugust 8, 202612 min read

Fix CORS Errors on Analytics Beacons: 2026 Guide

Your telemetry is silently failing. Here's how to fix CORS preflight issues.

The dashboard showed 12,000 daily active users. Our error tracking tool showed 47 errors total. Something wasn't right — CORS errors on analytics beacons were silently dropping our telemetry.

I opened Chrome DevTools on the live site and watched the Network tab. Every page load fired four requests to our telemetry endpoint. Every single one failed silently. No errors in the console. No red text. Just... nothing. The beacon requests showed up, completed with status 0, and vanished into the ether. Twelve thousand users, four beacons each, all disappearing.

(I felt like an idiot. The numbers had been wrong for weeks and I'd just assumed we had unusually stable users.)

That's the thing about CORS failures on telemetry. They don't crash your app. They don't throw visible errors. They just make your observability tools lie to you — confidently, consistently, without a hint of shame.

Three weeks. We'd been running with broken telemetry for three weeks before anyone noticed the numbers looked suspiciously low. Three weeks of errors we didn't catch, conversion funnels we couldn't measure, and incident timelines we couldn't reconstruct. I'm still a little embarrassed about it.

Why CORS Errors Hit Telemetry Differently Than API Calls

When your app's API call fails due to CORS, you notice immediately. The request throws, your try-catch triggers, the UI shows an error state. But telemetry requests are fire-and-forget by design. Nobody awaits them. Nobody error-handles them. The browser fails them silently — and that's actually the correct behavior for observability beacons. You don't want a tracking failure to break your checkout flow.

The problem is that "fails silently" means exactly that. Silent.

Here's what happens behind the scenes when you send cross-origin telemetry:

Scenario 1: Simple request, no preflight Your beacon uses POST with Content-Type: text/plain. The browser sends it directly. If the response lacks Access-Control-Allow-Origin, the browser blocks reading the response but often still delivers the payload. Your data might arrive even though DevTools shows a CORS error. Confusing? Absolutely. Maddening, actually.

Scenario 2: Non-simple request, preflight required Your beacon uses POST with Content-Type: application/json or includes custom headers like X-Session-Id. Browser sends an OPTIONS preflight first. If your server doesn't respond to OPTIONS with the right Access-Control-Allow-* headers, the actual POST never fires. Your data doesn't arrive. Nothing shows in your logs. You stare at empty dashboards wondering if anyone actually uses your product.

Scenario 3: Credentialed request You're sending cookies cross-origin (why though?). The server needs Access-Control-Allow-Credentials: true and can't use Access-Control-Allow-Origin: * — it must echo the specific origin. One misconfiguration and nothing works.

Most teams hit Scenario 2 without realizing it. They add a custom header somewhere — maybe an SDK does it automatically — and suddenly every beacon triggers preflight. The backend wasn't built to handle OPTIONS. Everything fails. I've done this exact thing twice and I should know better by now.

sendBeacon vs Fetch: Pick the Right Tool

The browser gives you two ways to send telemetry: navigator.sendBeacon() and fetch(). They're not interchangeable, and the CORS behavior differs.

sendBeacon was built for telemetry. Fire-and-forget. Survives page unload (mostly). Queues requests at low priority so it doesn't compete with user-critical resources. And critically: it only supports POST with CORS-safelisted content types, so it never triggers preflight.

// This will NOT trigger preflight
navigator.sendBeacon('https://telemetry.example.com/collect',
  new Blob([JSON.stringify(data)], { type: 'text/plain' })
);

// This ALSO won't trigger preflight
const formData = new FormData();
formData.append('payload', JSON.stringify(data));
navigator.sendBeacon('https://telemetry.example.com/collect', formData);

The trick is the content type. text/plain, application/x-www-form-urlencoded, and multipart/form-data are CORS-safelisted. No preflight needed. Your server receives the data; it just needs to parse JSON from a text/plain body (which is a little weird but works fine).

Fetch gives you more control but more CORS headaches.

// This WILL trigger preflight (application/json)
fetch('https://telemetry.example.com/collect', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(data),
  keepalive: true  // survive page unload, like sendBeacon
});

The keepalive: true flag makes fetch behave like sendBeacon for page unload scenarios. But you still get preflight because of the content type. If you control the server, respond to OPTIONS properly. If you don't — well, use sendBeacon.

My recommendation: Default to sendBeacon for analytics pageviews and simple events. Use fetch with keepalive when you need custom headers (authentication tokens, trace context propagation) and can guarantee your backend handles preflight. Honestly, most teams should just use sendBeacon and stop overthinking it.

The crossorigin Attribute: Fixing "Script error."

Here's a different CORS problem that drives teams crazy: you set up error tracking, and every exception from third-party scripts shows as Script error. with no stack trace. No file name. No line number. Nothing actionable.

This isn't a bug. It's a security feature.

When a script is loaded cross-origin without CORS headers, the browser intentionally hides error details. It prevents your page from learning anything about code it loaded from another domain. Makes sense for untrusted scripts. Terrible for your own scripts served from a CDN.

The fix has two parts:

Part 1: Add crossorigin to your script tag

<!-- Without crossorigin: errors are masked -->
<script src="https://cdn.example.com/app.js"></script>

<!-- With crossorigin: errors show full details -->
<script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script>

Part 2: Your CDN must send CORS headers

Access-Control-Allow-Origin: *

Both parts required. Miss either one and you're back to square one. If you add crossorigin="anonymous" but the server doesn't send the header, the script fails to load entirely. (Ask me how I learned this.) If the server sends the header but you don't add the attribute, errors stay masked.

For CloudFront, you need a response headers policy — similar to configuring CDN caching for analytics. For Cloudflare, it's a transform rule. For Nginx:

location ~* \\.js$ {
    add_header Access-Control-Allow-Origin "*" always;
}

(The "always" matters — it ensures the header appears on error responses too, which some setups miss.)

Server-Side: Handling Preflight Correctly

If you're building your own telemetry endpoint, here's the minimum viable CORS setup:

// Express.js example
app.options('/collect', (req, res) => {
  res.set({
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, X-Request-Id',
    'Access-Control-Max-Age': '86400'  // cache preflight for 24 hours
  });
  res.sendStatus(204);
});

app.post('/collect', (req, res) => {
  res.set('Access-Control-Allow-Origin', '*');
  // process telemetry...
  res.sendStatus(202);
});

Key points:

OPTIONS must return 2xx. A 404 or 405 on OPTIONS kills the preflight. The actual request never fires.

Max-Age caches preflight. Without it, every single request triggers a preflight. Set it to 86400 (24 hours) and browsers cache the preflight response. One OPTIONS per day per origin instead of one per request.

List your custom headers explicitly. If your SDK adds X-Trace-Id and you don't include it in Access-Control-Allow-Headers, preflight fails. Check what headers your client actually sends.

Don't use credentials unless necessary. Access-Control-Allow-Credentials: true forces you to echo specific origins instead of wildcarding. Extra complexity for minimal benefit in most analytics scenarios. Just don't.

Debugging CORS Failures

DevTools is your friend, but you need to know where to look.

Step 1: Check the Network tab for OPTIONS requests

Filter by method or search for "preflight." If you see OPTIONS requests returning 404 or 500, your server doesn't handle preflight. If you don't see OPTIONS at all for JSON requests, something's weird — maybe you're accidentally using a safelisted content type.

Step 2: Check response headers on actual requests

Click the failed request. Look at Response Headers. Is Access-Control-Allow-Origin present? Does it match your origin exactly (or is it *)? If the header is missing, that's your problem.

Step 3: Reproduce with curl

# Simulate preflight
curl -X OPTIONS https://telemetry.example.com/collect \
  -H "Origin: https://yoursite.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type" \
  -v

# Check response headers
curl -X POST https://telemetry.example.com/collect \
  -H "Origin: https://yoursite.com" \
  -H "Content-Type: application/json" \
  -d '{"test": true}' \
  -v

The -v flag shows headers. Look for the Access-Control-Allow-* lines in the response.

Step 4: Check the Console tab

CORS errors do appear in Console, but they're often buried. Search for "CORS" or "cross-origin." The message usually tells you exactly what's wrong — wrong origin, missing header, credentials mismatch.

Common Gotchas (I've Hit All of These)

Yeah, all of these. Multiple times.

Gotcha 1: localhost vs 127.0.0.1

http://localhost:3000 and http://127.0.0.1:3000 are different origins. If your Access-Control-Allow-Origin header lists one but you're testing from the other, CORS fails. Also: https://localhost and http://localhost are different. This one still gets me sometimes.

Gotcha 2: Port numbers matter

https://example.com and https://example.com:443 are technically the same, but https://example.com:8080 is a different origin. Your staging server on a non-standard port needs its own CORS entry.

Gotcha 3: Trailing slashes

Some servers get confused by Origin: https://example.com/ (with trailing slash). Browsers don't send the trailing slash in Origin headers, but if you're testing manually, you might accidentally add one. Consistency matters.

Gotcha 4: CDN caching the wrong response

If your CDN caches responses, it might cache a response without CORS headers when the first request came from a same-origin context. Subsequent cross-origin requests get the cached (wrong) headers. Vary on Origin or disable caching for telemetry endpoints.

Gotcha 5: Mixed content blocking

Sending telemetry from HTTPS pages to HTTP endpoints fails silently in most browsers. Not technically CORS, but often confused with it. Always use HTTPS for telemetry — this also matters for session replay security. (I know this seems obvious. It wasn't obvious to me at 2am debugging a production issue.)

JustAnalytics Handles This For You

Look — I've spent too many hours debugging CORS issues on telemetry endpoints. Way too many. It's one of those problems that seems simple ("just add the header!") but has a dozen edge cases that'll eat your afternoon.

JustAnalytics handles cross-origin telemetry out of the box. The under-5KB script uses sendBeacon with proper content types for simple events and falls back to fetch with keepalive when needed. Our collection endpoints handle preflight caching, credential-less mode, and origin validation correctly. If you're running error tracking, the SDK automatically adds crossorigin="anonymous" to your script tags during initialization so stack traces aren't masked.

Here's my honest opinion: most teams shouldn't be building their own telemetry infrastructure. It's not a competitive advantage. It's just plumbing.

And since JustAnalytics consolidates analytics, error tracking, APM, session replay, and uptime monitoring into a single script, you're dealing with one set of CORS headers instead of five. Less surface area for misconfiguration. Fewer 3am debugging sessions because your telemetry silently broke.

If you're currently juggling separate setups for Sentry-style error tracking and GA4-style analytics, that's multiple scripts, multiple endpoints, multiple CORS configurations. Something's going to break eventually. Probably at the worst possible time. For ad-heavy sites, you'll also want to consider click fraud protection since bot traffic distorts your analytics data.

For teams rolling their own OpenTelemetry collectors — and I've done this — the DevOS observability runbook has a solid section on OpenTelemetry collector CORS setup. Worth reading if you're going the self-hosted route.

Quick Reference: CORS Headers Cheat Sheet

HeaderPurposeExample Value
Access-Control-Allow-OriginWhich origins can access* or https://yourdomain.com
Access-Control-Allow-MethodsAllowed HTTP methodsPOST, OPTIONS
Access-Control-Allow-HeadersAllowed request headersContent-Type, X-Request-Id
Access-Control-Max-AgePreflight cache duration (seconds)86400
Access-Control-Allow-CredentialsAllow cookies/authtrue (avoid if possible)

For script tags loading cross-origin JavaScript:

Script AttributeCDN Header NeededEffect
NoneNoneErrors masked as "Script error."
crossorigin="anonymous"Access-Control-Allow-Origin: *Full error details visible
crossorigin="use-credentials"ACAO + Allow-CredentialsSends cookies to script origin

Frequently Asked Questions

Why does sendBeacon not trigger CORS preflight but fetch does?

sendBeacon uses POST with specific content types (text/plain, application/x-www-form-urlencoded, or multipart/form-data) that qualify as CORS-safelisted requests. These don't trigger preflight. Fetch with Content-Type: application/json or custom headers triggers an OPTIONS preflight. If your endpoint doesn't respond to OPTIONS correctly, the actual request never fires.

What does 'Script error.' with no stack trace mean?

Browsers mask error details from cross-origin scripts for security — they don't want your page to read exceptions from third-party code. You see 'Script error.' and nothing else. The fix is adding crossorigin='anonymous' to your script tag AND ensuring the script's server sends Access-Control-Allow-Origin headers.

Should analytics beacons send credentials (cookies) cross-origin?

Usually no. Credentialed cross-origin requests require Access-Control-Allow-Credentials: true and the origin cannot be wildcarded — your backend must echo the specific Origin header. For analytics, you rarely need cookies since you're typically sending a session ID in the payload. Skip credentials unless you have a specific use case like server-side session linking.

How do I test CORS headers without deploying to production?

Use curl with -H 'Origin: https://yourdomain.com' to simulate cross-origin requests locally. For preflight, send an OPTIONS request with Access-Control-Request-Method and Access-Control-Request-Headers. Check that responses include the correct Access-Control-Allow-* headers. Chrome DevTools Network tab also shows preflight requests — filter by 'method:OPTIONS' to find them.


Try JustAnalytics

All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts