Thomas Wang

From journeyman to master.


You May Not Know Beacon

TL;DR

Update: The webkit bug: "visibilitychange:hidden doesn't fire during page navigations" is fixed πŸŽ‰

What is Beacon

From W3C spec Beacon, Beacon is:

an interface that web developers can use to schedule asynchronous and non-blocking delivery of data that minimizes resource contention with other time-critical operations, while ensuring that such requests are still processed and delivered to destination.

The delivery of data is just an abstract way of saying the browser makes an HTTP request that sends back data to the server. The reason for another API that does HTTP when we already have XMLHttpRequest and Fetch API, is to address a typical challenge web developers have.

There are some HTTP requests from the browser that don't need to read or even wait for the server response, usually event tracking, status update, and analytics data. The characteristics of these type of requests are:

Keeping these in mind, the above description of the goals of Beacon API would make more sense.

The explicit goals of the Beacon API are to provide a minimal interface to web developers to specify the data and endpoint, then let the browser coalesces the requests.

Because Beacons do not provide response access in a fire-and-forget way and coalesced by the browser, the browser guarantees to initiate these data delivery requests before page is closed/unloaded, and outlive the page lifecycle.

How to Use

You can use beacon via navigator.sendBeacon(). A minimal example is given from the W3C spec:

<html>
<script>
// emit non-blocking beacon to record client-side event
function reportEvent(event) {
var data = JSON.stringify({
event: event,
time: performance.now()
});
navigator.sendBeacon('/collector', data);
}

// emit non-blocking beacon with session analytics as the page
// transitions to background state (Page Visibility API)
document.addEventListener('visibilitychange', function() {
if (document.visiblityState === 'hidden') {
var sessionData = buildSessionReport();
navigator.sendBeacon('/collector', sessionData);
}
});
</script>

<body>
<a href='http://www.w3.org/' onclick='reportEvent(this)'>
<button onclick="reportEvent('some event')">Click me</button>
</body>
</html>

MDN has the complete API documentation, go take a look!

Alternatives

People have used alternative ways to do what Beacon API meant to do.
By using XMLHttpRequest or fetch, you can POST data periodically in the background, and it's totally fine not to read the response.

Another way is to create an img element and leverages the fact it makes a GET request to server:

const img = new Image();
img.src = `https://mysite.com?${JSON.stringify(data)}`;

The problem is when the user closes the page, the last request is killed and there's no way to recover. In other words, a significant amount of your analytics data is lost and causes data distortion.

To avoid the closing page problem, a solution is to create a sync XHR on beforeunload or unload events, this is very bad for user experience as it blocks the page unloading - imagine your customers have to wait a noticeable amount of time to close the browser tab.

In fact, beforeunload and unload are explicitly said to be legacy API and should be avoided. See Page Lifecycle API > Legacy lifecycle APIs to avoid.

The Confusion

It seems easy, a simpler API that does the work reliably. However, people have had issues in production and not seeing the data being beaconed back as expected. Beacon API is broken post described their experiment setup and the results suggest Beacon API is not working as expected.

Reading through the comments section, the problem becomes clear that Beacon itself never had any issues, it is when to call the API.

MDN added you should use sendBeacon with visibilitychagne, not unload or beforeunload, after the comment discussions from the above post:

The navigator.sendBeacon() method asynchronously sends a small amount of data over HTTP to a web server. It’s intended to be used in combination with the visibilitychange event (but not with the unload and beforeunload events).

Other than blocking the page unloading, the two events unload and beforeunload are not reliably fired by the browser as you would expect.

Don't lose user and app state, use Page Visibility summarizes:

Therefore, on all mobile browsers, if you use sendBeacon on beforeunlaod:

document.addEventListener('beforeunload', navigatior.sendBeacon(url, data));

The callback function which sends the data is never triggered on mobile when the user swipes away or switches app.

To fix it, you should use visibilitychange event and beforeunload together.

A less wrong example looks like:

document.addEventListener('visibilitychange', () => {
if (getState() === 'hidden') {
flushData('hidden');
}
});
window.addEventListener('beforeunload', () => {
flushData('beforeunload');
});

Wait? Didn't we just say we should not use beforeunload? Firing on beforeunload is still necessary because Safari bug: visibilitychange:hidden doesn't fire during page navigations which is still active as Safari Version 14.0.2 (16610.3.7.1.9).

In practice, you also need to think about what to do with the fact that some clients not firing beforeunload and some not firing visibilitychange:hidden and potentially events you fired between last hidden and page unload, etc.

If you want to play with API and events by yourself and confirm, I've put up a demo at https://github.com/xg-wang/how-to-beacon/. Notice this is not for production, read more below.

Update
The webkit bug "visibilitychange:hidden doesn't fire during page navigations" is fixed πŸŽ‰

More on sendBeacon

Data size limit

The spec (3.1 sendBeacon Method) said:

The user agent MUST restrict the maximum data size to ensure that beacon requests are able to complete quickly and in a timely manner.

The restrict is intentionally vague here because the actual implementation is allowed to be different for different browser vendors.

An important thing to notice is the maximum data size is for in-flight data that the browser has not scheduled to sent. In other words, if a call to navigator.sendBeacon() returns false because exceeding the limit quota, trying to call navigator.sendBeacon() immediately after will not help.

When navigator.sendBeacon() returns false, a useful pattern is to fallback to fetch without the keepalive flag (more on that later), or xhr without the sync flag. The drawback is you lose the ability to deliver on page unload, but at least during normal sessions the data is not lost.

If you want to know the actual limit number - it's 64KB (w3c/beacon issue, wpt PR). However, you should not take that as a guarantee!

Delivery is not immediate

Unlike other network API, sendBeacon can be scheduled and coalesced by the browser. You can certainly contain timestamp data in the beacon payload, but the HTTP request time can be delayed.

It may throw error, be sure to catch

If the url parsing has error, sendBeacon will throw TypeError.

Another case is you can't pass reference without binding navigator:

// ❌
let s = navigator.sendBeacon;
s('/track', 'data');

// βœ…
s = navigator.sendBeacon.bind(navigator);
s('/track', 'data');

Server is encouraged to return 204 No Content

From: https://www.w3.org/TR/beacon/#sec-sendBeacon-method

Note
Beacon API does not provide a response callback. The server is encouraged to omit returning a response body for such requests (e.g. respond with 204 No Content).

Fetch keepalive

Beacon API uses Fetch keepalive under the hood, which is defined in the spec.

fetch('/track', {
method: 'POST',
body: getData(),
keepalive: true,
});
// Same as πŸ‘‡
navigator.sendBeacon('/track', getData());

This means they share the same data limitation, remember we discussed when falling back to fetch you don't need to add keepalive?

But unfortunately keepalive has limited browser support, while sendBeacon is available on all modern browsers.

Send Blob data

The second data param sent with sendBeacon is BodyInit, which means you can use Blob to create the data.

const obj = { hello: 'world' };
const blob = new Blob([JSON.stringify(obj, null, 2)], {
type: 'application/json',
});
navigator.sendBeacon('/track', blob);

When creating a application/json type request, it is no longer a simple request, and will trigger CORS preflight request. See A practical guide to CORS if you're not familiar with CORS.

Cannot use with compression API

There's a new API you can use to compress data on client side: compression

But it won't work with sendBeacon or Fetch keepalive, fetch will throw error when the keepalive request has stream body.

Service Worker

The service worker can operate async after the original document closes. (Tweeter thread)

Ideally, you can put all the existing data processing logic and beaconing to a service worker, to execute code off the main thread.

End word

Beacon is a simple API, but there are complexities coming from the heart of UI engineering. Use it with caution and always check your data.