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');
The first parameter is a normal URL, so query parameters work as expected.
navigator.sendBeacon('heartbeat?id=123');
The second parameter can be a string.
// String
const data = JSON.stringify({
location: window.location,
time: Date()
});
navigator.sendBeacon('usageString', data);
Or a FormData object.
// FormData
const formData = new FormData();
formData.append("session", "12345");
formData.append("id", 11);
navigator.sendBeacon('usageFormData', formData);
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);
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);
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));
}
}
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>
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);
});
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();
});
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>
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>
The window.onerror handler receives these values:
message: error messagesource: URL of the script that raised the errorlineno: line numbercolno: column numbererror: theErrorobject, when available
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
});
}
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>
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>
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.