You May Not Know Beacon
Tags: web, javascript
Published: 2021-01-20 06:15:26

---
title: You May Not Know Beacon
description: You may have used navigator.sendBeacon wrong.
tags: web,javascript
canonical_url: https://xgwang.me/posts/you-may-not-know-beacon/
published: true
---

## TL;DR

- `navigator.sendBeacon` should be used with `visibilitychange` and `beforeunload` events, otherwise you'll lose data
- `navigator.sendBeacon` has in-flight data limit and other constraints
- Use Beacon API with proper fallback to reliably beacon data to server

## What is Beacon

From [W3C spec Beacon](https://www.w3.org/TR/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](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) and [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/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:

- No need to access HTTP response - send and forget
- Lightweight - should not impact the user experience or taking too much network bandwidth
- Happen in the background without user interaction
- Need to **reliably send when closing the page** AKA, page unload

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
<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](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon), 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:

```javascript
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`](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event) or [`unload`](https://developer.mozilla.org/en-US/docs/Web/API/Window/unload_event) 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](https://developers.google.com/web/updates/2018/07/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](https://volument.com/blog/sendbeacon-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](https://volument.com/blog/sendbeacon-is-broken):

> The `navigator.sendBeacon()` method [asynchronously](https://developer.mozilla.org/en-US/docs/Glossary/Asynchronous) sends a small amount of data over [HTTP](https://developer.mozilla.org/en-US/docs/Glossary/HTTP) to a web server. It’s intended to be used in combination with the [`visibilitychange`](https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event) event (but not with the [`unload`](https://developer.mozilla.org/en-US/docs/Web/API/Window/unload_event) and [`beforeunload`](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event) 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](https://www.igvita.com/2015/11/20/dont-lose-user-and-app-state-use-page-visibility/) summarizes:

- `beforeunload` is of limited value as it only fires on desktop navigation.
- `unload` does not fire on mobile and desktop Safari.

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

```javascript
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:

```javascript
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](https://bugs.webkit.org/show_bug.cgi?id=151234) 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.

## More on `sendBeacon`

### Data size limit

The spec ([3.1 sendBeacon Method](https://www.w3.org/TR/beacon/#sec-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](https://github.com/w3c/beacon/issues/38), [wpt PR](https://github.com/web-platform-tests/wpt/pull/4024)). 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`:

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

// ✅
s = navigator.sendBeacon.bind(navigator);
s('/track', 'data');
```

- FireFox: `Uncaught TypeError: 'sendBeacon' called on an object that does not implement interface Navigator.`
- Safari: `TypeError: Can only call Navigator.sendBeacon on instances of Navigator`
- Chrome: `TypeError: Illegal invocation`

### 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.

```javascript
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.

- https://caniuse.com/mdn-api_request_keepalive
![2021-01-19-0857-caniuse-api_request_keepalive](https://dev-to-uploads.s3.amazonaws.com/i/8ia54q64krhu60tkyjgj.png)

- https://caniuse.com/beacon
![2021-01-19-0858-caniuse-beacon](https://dev-to-uploads.s3.amazonaws.com/i/7yn21x0lvsl6545iko9n.png)
 

### Send Blob data

The second `data` param sent with `sendBeacon` is [`BodyInit`](https://fetch.spec.whatwg.org/#bodyinit), which means you can use `Blob` to create the data.

```javascript
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](https://xgwang.me/posts/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](https://github.com/wicg/compression/blob/master/explainer.md#deflate-compress-an-arraybuffer)

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](https://twitter.com/wanderview/status/1234126801049444352))

Ideally, you can put all the existing data processing logic and beaconing to a service worker, to execute code [off the main thread](https://web.dev/off-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.