navigator.sendBeacon: Reliably Send Data on Page Unload
AI generated
JS
() =>
JavaScript · Browser APIs · Analytics · Networking
navigator.sendBeacon
Reliably Send Data on Page Unload

Using fetch or XHR on page unload is a race condition against the browser. When a tab is closed or the page is unloaded, the browser aborts any open requests. navigator.sendBeacon guarantees delivery of data without delaying navigation, which makes it the right solution for analytics, session data and exit tracking.

10 min read sendBeacon · Beacon API · beforeunload · visibilitychange · fetch keepalive All modern browsers

1. The problem: why fetch fails on page unload

Every browser tab has a lifecycle: it loads, becomes active, is possibly moved to the background, and is eventually closed or navigated away from. The problem is that when the browser unloads a page, whether through a tab close, navigation, or refresh, it aborts all open HTTP requests. Fetch requests started inside a beforeunload handler frequently never reach the server, because the process is aborted before the connection is fully established. This is not a browser bug but correct behavior: the browser prioritizes navigation over pending network operations.

In practice, this means analytics events for bounces, session duration metrics, form abandonment data and exit survey answers are regularly lost when sent via normal fetch or XHR requests inside beforeunload handlers. The old workaround, issuing a synchronous XHR with xhr.open('POST', url, false), blocks navigation for the entire network round trip and is locked out in many contexts in modern browsers. navigator.sendBeacon was designed to solve exactly this problem without hurting performance or user experience.

2. How navigator.sendBeacon works

navigator.sendBeacon hands off an HTTP request to the browser's networking stack with the guarantee that the request will be sent, even if the triggering page has already been unloaded. The browser treats beacon requests like a queue: they are detached from the page context and processed independently of the page's lifecycle. The triggering page is never blocked, navigator.sendBeacon returns synchronously and the actual network operation runs asynchronously in the background.

Internally, the browser uses the same mechanisms as for regular HTTP requests, HTTP/2 multiplexing, keep-alive connections, TLS session reuse. The difference lies in prioritization: beacon requests are sent with low network priority (similar to fetch({priority: 'low'})), since they need no response and should not affect user interaction. The request is always an HTTP POST. There is no response callback, the browser ignores the server's response entirely. This makes navigator.sendBeacon ideal for fire-and-forget data transmission, but unsuitable for requests that need a server response.

3. Syntax, return value and payload types

navigator.sendBeacon has a simple signature: navigator.sendBeacon(url, data). The return value is a boolean: true if the browser successfully queued the request, and false if the queue is full or the browser rejected the request (for example because the payload is too large). A true return value does not mean the server received the request, it only means the browser will attempt to send it.

The data parameter accepts several types: Blob, ArrayBufferView, FormData, URLSearchParams and DOMString (a plain string). The most common use case is a JSON string passed as a Blob with an explicitly set content type: new Blob([JSON.stringify(data)], { type: 'application/json' }). If you pass a plain string instead, the browser sets the content type to text/plain;charset=UTF-8. For servers that expect JSON, the blob variant is the correct way to control the content type.


// navigator.sendBeacon, basic usage and payload types

const endpoint = '/api/analytics/beacon';

// 1. JSON payload as Blob with explicit Content-Type
function sendBeaconJSON(data) {
  const payload = new Blob([JSON.stringify(data)], {
    type: 'application/json',
  });
  const success = navigator.sendBeacon(endpoint, payload);

  if (!success) {
    console.warn('[beacon] Queue full or request rejected, fallback to fetch');
    // Fallback for critical data
    fetch(endpoint, {
      method: 'POST',
      body: JSON.stringify(data),
      headers: { 'Content-Type': 'application/json' },
      keepalive: true, // fetch keepalive as alternative
    }).catch(() => {}); // ignore errors on page unload
  }
  return success;
}

// 2. FormData payload (no Content-Type control needed)
function sendBeaconFormData(fields) {
  const form = new FormData();
  for (const [key, value] of Object.entries(fields)) {
    form.append(key, value);
  }
  return navigator.sendBeacon(endpoint, form);
}

// 3. URLSearchParams (simple key-value pairs)
function sendBeaconParams(params) {
  const body = new URLSearchParams(params);
  return navigator.sendBeacon(endpoint, body);
}

4. The right trigger: visibilitychange instead of beforeunload

The beforeunload event is the intuitive choice for page-unload tracking, but it is problematic for two reasons. First, it does not fire in all page-unload scenarios, in particular not on mobile browsers that aggressively suspend tabs, and not for navigation via the browser back button in some implementations. Second, a beforeunload handler that sets a returnValue prevents the browser from using the back-forward cache (bfcache), which makes navigation noticeably slower.

The recommended alternative for navigator.sendBeacon calls is the visibilitychange event combined with document.visibilityState === 'hidden'. This event fires when the tab moves to the background (the user switches to another tab), when the mobile device locks the screen, and when the page is closed or navigated away from. It is more consistent than beforeunload and does not affect the bfcache. Google Analytics 4 and the Web Vitals library explicitly use visibilitychange instead of beforeunload for their beacon calls.


// Correct trigger pattern: visibilitychange instead of beforeunload
const sessionData = {
  startTime: Date.now(),
  events: [],
  path: window.location.pathname,
};

function flushSession() {
  if (sessionData.events.length === 0) return;

  const payload = {
    ...sessionData,
    endTime: Date.now(),
    duration: Date.now() - sessionData.startTime,
  };

  const blob = new Blob([JSON.stringify(payload)], {
    type: 'application/json',
  });

  // Send beacon, guaranteed delivery even when page is being unloaded
  const sent = navigator.sendBeacon('/api/session/end', blob);

  if (sent) {
    sessionData.events = []; // Reset after successful queue
  }
}

// Preferred: fires on tab switch, mobile lock, navigation and close
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    flushSession();
  }
});

// Additional safety net for desktop browsers
window.addEventListener('pagehide', flushSession, { once: true });

// Track user interactions for session data
document.addEventListener('click', (e) => {
  sessionData.events.push({
    type: 'click',
    target: e.target.tagName,
    timestamp: Date.now(),
  });
});

5. CORS behavior of the Beacon API

The CORS behavior of navigator.sendBeacon is an important and often misunderstood aspect. Beacon requests follow the same CORS rules as regular fetch requests, but with one crucial restriction: navigator.sendBeacon always sends a plain POST request without custom headers. If the payload is a Blob with application/json as the content type, it counts as a CORS preflight request, meaning the browser first sends an OPTIONS request before the actual beacon request is sent.

For CORS-free beacon requests, it is advisable to use text/plain as the content type (no preflight required) or application/x-www-form-urlencoded via URLSearchParams. If the beacon endpoint is on the same origin as the page (same-origin), there are no CORS restrictions. For cross-origin tracking endpoints, for example when an analytics service lives on a different domain, the server must return correct CORS headers: Access-Control-Allow-Origin and Access-Control-Allow-Methods: POST. The preflight delay can be problematic if the page unloads quickly, before the OPTIONS response arrives.

6. Payload limits and error handling

The Beacon API has a payload limit that is browser-specific and typically sits around 64 KB. If the payload exceeds this limit, navigator.sendBeacon returns false without sending the request. That is the only synchronous feedback the API offers. A successful queue acceptance (a true return value) gives no guarantee about delivery status, network errors, server timeouts and 5xx responses are ignored by the browser and never reported back to JavaScript.

For applications where data loss is unacceptable, there are three strategies. First, cache data in localStorage or IndexedDB and send it via a regular fetch on the next page load, the so-called "dead-drop" pattern. Second, send data in small batches that stay under the 64 KB limit. Third, use fetch({keepalive: true}) as a fallback, which offers similar guarantees to navigator.sendBeacon but gives full control over headers and method. Combining sendBeacon as the primary method with localStorage as a secondary store is the most robust solution for critical tracking data.

7. fetch keepalive as a modern alternative

fetch({keepalive: true}) is a more modern alternative to navigator.sendBeacon that is available in all current browsers. Unlike the Beacon API, fetch keepalive allows full control over the HTTP method, headers and request body. It supports all HTTP methods (not just POST), allows custom headers (Authentication, X-API-Key), and returns a promise that could theoretically be evaluated, although that is practically impossible in a page-unload context since the JavaScript context is no longer active.

The decisive advantage of navigator.sendBeacon over fetch keepalive: sendBeacon has no restrictions regarding the calling context. Fetch keepalive can fail to work in service workers and some browser contexts. For most analytics use cases, the two methods are equivalent. Best practice: use navigator.sendBeacon as the primary method, with fetch keepalive as a fallback when sendBeacon returns false or is unavailable.

8. sendBeacon vs. fetch keepalive vs. XHR compared

Choosing the right method for page-unload data transmission depends on the specific requirements. navigator.sendBeacon is the simplest and most focused solution: no response, always POST, broad browser support, minimal code. Fetch keepalive is more flexible: any HTTP method, custom headers, a response promise. Synchronous XHR is locked out in many contexts in modern browsers, blocks navigation, and should not be used in any new code.

The most important difference in practice: navigator.sendBeacon is optimized for use cases without a response requirement. If the endpoint only returns a 204 status and no further processing of the response is needed, sendBeacon is the optimal choice. If the endpoint needs authentication via custom headers, fetch keepalive is the better option. Both methods share the same 64 KB limit problem, which can only be solved through client-side batching or server-side streaming.

Method Page-unload reliability Custom headers Response readable
navigator.sendBeacon High (browser queue) No No
fetch keepalive: true High (similar to Beacon) Yes Theoretically (not in practice)
fetch (normal) Low (aborted) Yes Yes
Sync XHR Medium (blocks UI) Yes Yes

9. Practical example: a robust analytics queue with sendBeacon

A production-ready analytics queue built with navigator.sendBeacon combines three mechanisms: batch accumulation (events are collected, not sent immediately), regular flush scheduling (for example every 30 seconds via requestIdleCallback), and guaranteed flushing on page unload via visibilitychange. The localStorage backup covers the case where the browser clears the beacon queue before the request was actually sent, a rare but possible scenario during browser crashes or forced terminations.

The server endpoint for navigator.sendBeacon must account for one particularity: the browser expects a fast response but does not process it. A 204 status with no body is ideal. The endpoint should write the received data asynchronously into a queue and respond immediately, without waiting on database writes or complex processing. This mirrors the CQRS pattern on the server side: commands (beacon data) are received and processed asynchronously, and the HTTP response merely acknowledges receipt.


// Production analytics queue using sendBeacon with localStorage backup
class BeaconQueue {
  constructor(endpoint, options = {}) {
    this.endpoint = endpoint;
    this.queue = this.restoreFromStorage(); // Recover unsent events
    this.maxSize = options.maxSize ?? 50;
    this.flushInterval = options.flushInterval ?? 30000;
    this.storageKey = options.storageKey ?? 'beacon_queue';

    // Periodic flush via idle scheduling
    this.scheduleFlush();

    // Guaranteed flush on page hide
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') this.flushSync();
    });
    window.addEventListener('pagehide', () => this.flushSync(), { once: true });
  }

  push(event) {
    this.queue.push({ ...event, queuedAt: Date.now() });
    this.saveToStorage();

    // Flush immediately if queue is full
    if (this.queue.length >= this.maxSize) {
      this.flushSync();
    }
  }

  flushSync() {
    if (this.queue.length === 0) return true;

    const batch = this.queue.splice(0, this.maxSize);
    const blob = new Blob([JSON.stringify(batch)], {
      type: 'application/json',
    });

    const sent = navigator.sendBeacon(this.endpoint, blob);

    if (sent) {
      // Clear storage for sent events
      this.saveToStorage();
    } else {
      // Put events back, sendBeacon rejected (queue full)
      this.queue.unshift(...batch);
      console.warn('[beacon] Queue full, events preserved in storage');
    }
    return sent;
  }

  scheduleFlush() {
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => {
        this.flushSync();
        setTimeout(() => this.scheduleFlush(), this.flushInterval);
      }, { timeout: this.flushInterval });
    } else {
      setTimeout(() => { this.flushSync(); this.scheduleFlush(); }, this.flushInterval);
    }
  }

  saveToStorage() {
    try {
      localStorage.setItem(this.storageKey, JSON.stringify(this.queue));
    } catch {
      /* Storage might be full or unavailable */
    }
  }

  restoreFromStorage() {
    try {
      const stored = localStorage.getItem(this.storageKey);
      return stored ? JSON.parse(stored) : [];
    } catch {
      return [];
    }
  }
}

// Initialization
const analytics = new BeaconQueue('/api/analytics/batch', {
  maxSize: 30,
  flushInterval: 20000,
});

analytics.push({ event: 'page_view', path: location.pathname });

10. Summary

navigator.sendBeacon solves the problem of unreliable data transmission on page unload reliably and without any performance trade-offs. The browser takes responsibility for the transmission regardless of whether the page has already been unloaded. visibilitychange is the more reliable trigger than beforeunload, because it fires more consistently and does not block the bfcache. The Blob pattern with an explicit content type is the correct way to transmit JSON data.

The most important limitations: no response callback, always POST, a 64 KB payload limit, no custom header support. For use cases that need custom headers or other HTTP methods, fetch({keepalive: true}) is the alternative. The localStorage backup pattern is the robust complement for scenarios where even navigator.sendBeacon can fail. A production-ready analytics queue combines sendBeacon, requestIdleCallback for periodic flushing, and localStorage as a persistent buffer for guaranteed data delivery.

Mironsoft

JavaScript analytics, tracking infrastructure and browser APIs

Analytics data that actually arrives?

We audit your existing analytics implementation for data loss on page unload, replace fragile fetch/XHR patterns with sendBeacon-based queues, and build robust localStorage backups for critical event data.

Analytics audit

Identify beforeunload hacks and unsafe fetch calls on page unload

Queue implementation

Build a robust beacon queue with localStorage backup and rIC scheduling

Server endpoint

Fast 204 endpoints with asynchronous processing for beacon payloads

navigator.sendBeacon, the essentials at a glance

Browser queue guarantee

sendBeacon hands the request to the browser's networking stack. Transmission continues even after the page has been unloaded, independent of the JavaScript context's lifecycle.

The right trigger

visibilitychange plus document.visibilityState === 'hidden' is more reliable than beforeunload. No bfcache blocking, consistent behavior on mobile devices.

Payload pattern

JSON as a Blob with content type 'application/json'. A false return means the queue is full or the payload exceeds 64 KB. Fetch keepalive as a fallback for custom header requirements.

localStorage backup

Only remove sent events from storage after a successful sendBeacon call. On the next page load, catch up unsent events via a regular fetch.

11. FAQ: navigator.sendBeacon

1What is navigator.sendBeacon?
A browser API for guaranteed data transmission even after the page has been unloaded. Ideal for analytics, session tracking and exit data.
2Why is fetch unreliable on page unload?
The browser aborts open requests on page unload. sendBeacon hands off to the browser stack, which sends independently of the page context.
3What does the boolean return value mean?
true means it was accepted into the browser queue. false means the queue is full or the payload exceeds about 64 KB. No proof of server receipt.
4Is visibilitychange better than beforeunload?
More consistent: fires on tab switch, mobile lock and navigation. Does not block the bfcache. GA4 also uses visibilitychange.
5Sending JSON with sendBeacon?
As a Blob: new Blob([JSON.stringify(data)], {type: 'application/json'}). A plain string sets the content type to text/plain.
6Are custom headers possible?
No. For endpoints that need custom headers, use fetch({keepalive: true}) as an alternative.
7What is the payload limit?
Typically about 64 KB. If exceeded: false return, no transmission. Batching or splitting the payload as a solution.
8What about CORS behavior?
application/json triggers a preflight. Use URLSearchParams or text/plain for CORS-free requests. Same-origin: no restrictions.
9sendBeacon vs. fetch keepalive?
sendBeacon: simple, always POST, no headers. fetch keepalive: all methods, custom headers, promise. Similar reliability on page unload.
10Implementing a localStorage backup?
Store events before sending. Remove after a successful sendBeacon. On the next page load, catch up unsent events via a regular fetch.