Skip to content
Carl Victor Fontanos
Carl Victor Fontanos

Carl Victor Fontanos

Software Engineer

I build web applications and share what I learn along the way.

© 2026

Why Your Analytics Miss Closing Tabs (and How sendBeacon Fixes It)

C
Carlo Fontanos
· 2 min read

I once spent a day convinced our reading-time tracking was broken because the numbers were absurdly low. The code was fine. The problem was physics: we sent the data with fetch() inside an unload handler, and the browser killed the page before the request ever hit the wire.

Browsers aggressively tear down closing pages. In-flight requests get cancelled, timers stop, and anything async in an unload handler is on borrowed time. That's what navigator.sendBeacon() is for:

document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
        const data = JSON.stringify({
            page: location.pathname,
            secondsOnPage: Math.round((Date.now() - start) / 1000),
        });
        navigator.sendBeacon('/track', new Blob([data], { type: 'application/json' }));
    }
});

sendBeacon hands the request to the browser itself, which guarantees it will be delivered even after the page is gone. It's fire-and-forget by design: always a POST, no response, no retry logic for you to write.

Use visibilitychange, not unload

This is the half of the trick people miss. The unload and beforeunload events are unreliable on mobile (a swiped-away tab may never fire them) and they disable back/forward cache in some browsers, making your whole site feel slower. The visibilitychange event firing with state "hidden" is the last moment you can trust on every platform, including iOS Safari. Send your beacon there.

Limits worth knowing

  • The payload is capped (64 KB is the typical budget across the queue). Batch your events, don't stream them.
  • You can't set custom headers. If your endpoint expects a CSRF token, put it in the body or exempt the route - a beacon endpoint should be treated like a webhook.
  • The return value tells you if the beacon was queued, not delivered. There is no delivery callback, ever.

On the PHP side, a beacon endpoint is as plain as it gets: read php://input, insert, return 204. No response body needed, since nobody is listening.

If you log JavaScript errors to your server (here's my setup for that), route those through sendBeacon too - errors that happen right before a rage-close are exactly the ones you want to catch.

C
Written by Carlo Fontanos

Full-stack web developer sharing practical tutorials and building tools that ship.

Got something on your mind?

My inbox is open - no forms disappearing into the void here.

  • Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
  • Hire me for a project Have something custom in mind? Let's talk scope and timelines.
  • Product support Bought something here? I'll help you get it running.

I usually reply within 1-2 business days.

Message sent!

Your details are only used to reply to you.

Keep reading