Home | Send Feedback | Share on Bluesky |

A closer look at the Beacon API

Published: 5. September 2018  •  Updated: 21. March 2026  •  javascript

The Beacon API is a small browser API for sending short, fire-and-forget POST requests to a server. It is especially useful for analytics, diagnostics, and session handoff data that should still be queued while a page is moving into the background.

Browser support is strong in modern browsers. You can check the current compatibility table here: https://caniuse.com/beacon

The API consists of a single method on navigator:

navigator.sendBeacon(url, data);

sendBeacon() always uses POST. The optional body can be a string, FormData, Blob, or an ArrayBufferView. The method returns a boolean that tells you whether the browser accepted the payload for delivery.

The request is queued asynchronously and the browser does not expose the response to your code. That makes the API a good fit for telemetry and a poor fit for workflows that require custom headers, a non-POST method, or a response body. In those cases, use fetch() with the keepalive option instead.

The Beacon API is designed for small payloads. Browsers also treat delivery as best effort. There is no built-in retry strategy, and you should not assume offline requests will be retried later.

Usage

An application can call sendBeacon() without a body.

    navigator.sendBeacon('heartbeat');

usage.html

The first parameter is a normal URL, so query parameters work as expected.

    navigator.sendBeacon('heartbeat?id=123');

usage.html

The second parameter can be a string.

    // String
    const data = JSON.stringify({
      location: window.location,
      time: Date()
    });
    navigator.sendBeacon('usageString', data);

usage.html

Or a FormData object.

    // FormData
    const formData = new FormData();
    formData.append("session", "12345");
    formData.append("id", 11);
    navigator.sendBeacon('usageFormData', formData);

usage.html

Or a Blob.

    // Blob
    const ua = JSON.stringify({ ua: navigator.userAgent, now: performance.now() });
    const headers = { type: 'application/json' };
    const blob = new Blob([ua], headers);
    navigator.sendBeacon('usageBlob', blob);

usage.html

Or an ArrayBufferView. In this example, the payload is compressed and sent as a typed array.

    // ArrayBufferView
    const string = '=======This is a text, sent compressed to the server=======';
    const enc = new TextEncoder();
    const encoded = enc.encode(string);
    const compressed = pako.deflate(encoded);

    navigator.sendBeacon('usageArrayBufferView', compressed);

usage.html

All of these calls end up as POST requests. You cannot change the HTTP method, and you cannot read a response.

I wrote a small Spring Boot backend for these examples. You can find the request handlers here:

  @ResponseStatus(code = HttpStatus.NO_CONTENT)
  @PostMapping("/heartbeat")
  public void heartbeat(@RequestParam(name = "id", required = false) Long id) {
    System.out.println(id);
    System.out.println("heartbeat called");
  }

  @ResponseStatus(code = HttpStatus.NO_CONTENT)
  @PostMapping("/usageString")
  public void usageString(@RequestBody String data) {
    System.out.println(data);
  }

  @ResponseStatus(code = HttpStatus.NO_CONTENT)
  @PostMapping("/usageBlob")
  public void usageBlob(@RequestBody Map<String, Object> data) {
    System.out.println(data.get("ua"));
    System.out.println(data.get("now"));
  }

  @ResponseStatus(code = HttpStatus.NO_CONTENT)
  @PostMapping("/usageFormData")
  public void usageFormData(@RequestParam("session") String session,
      @RequestParam("id") long id) {
    System.out.println(session);
    System.out.println(id);
  }

  @ResponseStatus(code = HttpStatus.NO_CONTENT)
  @PostMapping("/usageArrayBufferView")
  public void usageArrayBufferView(@RequestBody byte[] data) throws DataFormatException {
    try (Inflater decompresser = new Inflater()) {
      decompresser.setInput(data, 0, data.length);
      byte[] inflated = new byte[2048];
      int resultLength = decompresser.inflate(inflated);
      decompresser.end();

      System.out.println(new String(inflated, 0, resultLength));
    }
  }

UsageController.java

Examples

The following examples all send data to a Spring Boot backend. The corresponding server-side handlers are here.

Page Lifecycle

The first example uses the Page Visibility API together with pagehide to detect when the document is leaving the foreground.

<!doctype html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <title>Lifecycle: Page 1</title>
</head>

<body>
  <h1>Lifecycle: This is Page 1</h1>

  <div>
    <a href="page2.html">Go to Page 2</a>
  </div>
  <script src="main.js"></script>
</body>

</html>

index.html

The script queues one beacon when the page becomes hidden or when pagehide fires. This is the pattern I prefer for end-of-session analytics because it does not rely on blocking navigation.

const analytics = {
  start: performance.now(),
  stop: null,
  hiddenAt: null
};

let beaconSent = false;

function sendAnalytics(timeStamp) {
  if (beaconSent) {
    return;
  }

  beaconSent = true;
  analytics.stop = performance.now();
  analytics.hiddenAt = timeStamp;
  navigator.sendBeacon('../lifecycle', JSON.stringify(analytics));
}

document.addEventListener('visibilitychange', event => {
  if (document.visibilityState === 'hidden') {
    sendAnalytics(event.timeStamp);
  }
});

window.addEventListener('pagehide', event => {
  sendAnalytics(event.timeStamp);
});

main.js


Performance

The second example uses PerformanceObserver and the Navigation Timing API to collect page performance metrics. It tracks navigation timing, First Contentful Paint (FCP), Largest Contentful Paint (LCP), Cumulative Layout Shift (CLS), and an Interaction to Next Paint (INP) approximation based on Event Timing entries.

const performanceData = {
  navigationStart: null,
  domContentLoadedEnd: null,
  loadComplete: null,
  fcp: null,
  lcp: null,
  cls: 0,
  inp: null,
  sessionDuration: null,
  timestamp: null
};

const sessionStart = performance.now();
let beaconSent = false;

function updateNavigationTiming() {
  const navigationTiming = performance.getEntriesByType('navigation')[0];
  if (!navigationTiming) {
    return;
  }

  performanceData.navigationStart = navigationTiming.fetchStart;
  performanceData.domContentLoadedEnd = navigationTiming.domContentLoadedEventEnd;
  performanceData.loadComplete = navigationTiming.loadEventEnd;
}

function observeEntry(type, callback, options = {}) {
  if (!PerformanceObserver.supportedEntryTypes.includes(type)) {
    return null;
  }

  const observer = new PerformanceObserver(list => {
    for (const entry of list.getEntries()) {
      callback(entry);
    }
  });

  observer.observe({ type, buffered: true, ...options });
  return observer;
}

const observers = [
  observeEntry('paint', entry => {
    if (entry.name === 'first-contentful-paint') {
      performanceData.fcp = entry.startTime;
    }
  }),
  observeEntry('largest-contentful-paint', entry => {
    performanceData.lcp = entry.startTime;
  }),
  observeEntry('layout-shift', entry => {
    if (!entry.hadRecentInput) {
      performanceData.cls += entry.value;
    }
  }),
  observeEntry('event', entry => {
    if (entry.interactionId > 0) {
      performanceData.inp = Math.max(performanceData.inp ?? 0, entry.duration);
    }
  }, { durationThreshold: 40 })
].filter(Boolean);

function sendPerformance() {
  if (beaconSent) {
    return;
  }

  beaconSent = true;
  updateNavigationTiming();
  performanceData.sessionDuration = Math.round(performance.now() - sessionStart);
  performanceData.timestamp = Date.now();

  navigator.sendBeacon('../performance', JSON.stringify(performanceData));

  for (const observer of observers) {
    observer.disconnect();
  }
}

updateNavigationTiming();

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    sendPerformance();
  }
});

window.addEventListener('pagehide', () => {
  sendPerformance();
});

main.js

The script buffers the measurements while the page is active and sends one beacon when the document is hidden.


Web Vitals

Another option is the web-vitals library. It provides higher-level helpers for the current Core Web Vitals and handles a number of browser differences for you.

  <script type="module">
    import {onCLS, onINP, onLCP} from 'https://unpkg.com/web-vitals@5?module';

    function sendToAnalytics(metric) {
    const body = JSON.stringify({
        name: metric.name,
        value: metric.value,
        rating: metric.rating,
        delta: metric.delta,
        id: metric.id,
        navigationType: metric.navigationType,
        attribution: metric.attribution ? JSON.stringify(metric.attribution) : null 
    });

    navigator.sendBeacon('../webVitals', body);
    }

    onCLS(sendToAnalytics);
    onINP(sendToAnalytics);
    onLCP(sendToAnalytics);
  </script>

index.html

This example forwards CLS, INP, and LCP measurements directly to the server with sendBeacon().


Global Error Handling

Runtime errors that only happen in a user's browser are often hard to reproduce. A beacon can send them to the server as soon as they happen.

The example listens for both synchronous script errors and unhandled promise rejections, serializes the useful parts, and forwards them with FormData.

  <script>
    window.onerror = function (msg, url, line, col, error) {
      const formData = new FormData();
      formData.append('message', String(msg));
      formData.append('url', url);
      formData.append('line', line);
      formData.append('col', col);
      formData.append('error', error?.stack || error?.message || 'Unknown error');
      navigator.sendBeacon('../clientError', formData);
    };

    window.addEventListener('unhandledrejection', event => {
      const formData = new FormData();
      formData.append('message', 'Unhandled promise rejection');
      formData.append('url', window.location.href);
      formData.append('line', 0);
      formData.append('col', 0);
      formData.append('error', event.reason?.stack || String(event.reason));
      navigator.sendBeacon('../clientError', formData);
    });

    setTimeout(() => {
      const a = b * b;
    }, 1000);

  </script>

index.html

The window.onerror handler receives these values:

The error event can also be registered with addEventListener(), where the callback receives a single event object instead.

window.addEventListener('error', event => { ... })

The event object exposes these read-only properties: event.message, event.filename, event.lineno, event.colno, and event.error. They correspond to the onerror parameters.


Position

The Geolocation API allows a web application to access the user's location, but only after the user grants permission.

If permission is granted, watchPosition() reports updates whenever the device location changes. The example displays the current coordinates and forwards latitude, longitude, and timestamp to the server.

    document.getElementById('startWatchButton').addEventListener('click', event => {
      startListening();
      event.target.disabled = true;
    });

    function startListening() {
      navigator.geolocation.watchPosition(pos => {

        document.getElementById('latitude').textContent = pos.coords.latitude;
        document.getElementById('longitude').textContent = pos.coords.longitude;

        const position = {
          latitude: pos.coords.latitude,
          longitude: pos.coords.longitude,
          ts: pos.timestamp
        };
        navigator.sendBeacon('../position', JSON.stringify(position));
      }, error => {
        console.error(error.message);
      }, {
        enableHighAccuracy: true,
        maximumAge: 10000
      });
    }

index.html

Visit MDN for more information about the Geolocation API.


Reporting Observer

Browsers can emit deprecation and intervention reports that never pass through your normal error handlers. ReportingObserver gives you access to those browser-generated reports.

The demo first checks for API support, then starts observing buffered reports, and finally triggers a browser-generated deprecation report so the callback has something to send.

  <script>
    if (!('ReportingObserver' in window)) {
      document.body.insertAdjacentHTML('beforeend', '<p>ReportingObserver is not supported in this browser.</p>');
    }
    else {
      const observer = new ReportingObserver(reports => {
        for (const report of reports) {
          navigator.sendBeacon('../reportObserver', JSON.stringify(report.body, ['id', 'columnNumber', 'lineNumber', 'message', 'sourceFile']));
        }
      }, { types: ['intervention', 'deprecation'], buffered: true });

      observer.observe();

      const request = new XMLHttpRequest();
      request.open('GET', 'https://api.chucknorris.io/jokes/random', false);
      request.send(null);

      if (request.status === 200) {
        console.log(request.responseText);
      }
    }
  </script>

index.html

This API still has limited support, with Chromium-based browsers offering the broadest coverage.

More information: https://developer.chrome.com/docs/capabilities/web-apis/reporting-observer


Generic Sensor API

The Generic Sensor API provides access to device sensors such as accelerometers.

The example feature-detects the Accelerometer interface, starts the sensor, updates the page with the current readings, and sends each measurement to the server with a beacon.

  <script>
    if (!('Accelerometer' in window)) {
      document.body.insertAdjacentHTML('beforeend', '<p>Accelerometer is not supported in this browser.</p>');
    }
    else {
      const sensor = new Accelerometer({ frequency: 10 });
      sensor.start();

      sensor.onreading = () => {
        const formData = new FormData();
        formData.append('x', sensor.x);
        formData.append('y', sensor.y);
        formData.append('z', sensor.z);
        navigator.sendBeacon('../sensor', formData);

        document.getElementById('x').innerText = sensor.x;
        document.getElementById('y').innerText = sensor.y;
        document.getElementById('z').innerText = sensor.z;
      };

      sensor.onerror = event => console.log(event.error.name, event.error.message);
    }

  </script>

index.html

Browser support for this API is still limited. For up-to-date support information, check: https://developer.chrome.com/docs/capabilities/web-apis/generic-sensor

Conclusion

The Beacon API is a practical solution when you need to send a small POST request in the background and do not care about the response.

It is a strong fit for analytics events, performance measurements, client-side error reports, and similar telemetry.

Keep payloads small, treat delivery as best effort, and test across the browsers you care about.

If you need request headers, another HTTP method, or access to the response, prefer:

fetch('/endpoint', {
  method: 'POST',
  body: JSON.stringify(payload),
  headers: { 'Content-Type': 'application/json' },
  keepalive: true
});

That pattern covers the cases where sendBeacon() is intentionally too limited.