Posted by Rachel Normand on March 04, 2021 | browsers

We recently spent some time investigating the best way to make an XHR request when a user closes their browser window. We had received reports that some learning content in our LMS was not being saved when the user closed the window. We wondered if we could trigger a save just before the browser was closed. In the end this was not the right solution for us due to the size of data we wanted to send. APIs for making XHR requests on page close are designed for sending analytics data and have a limit of 64KB. We wanted to send more than this, which led us to realise we were approaching the problem in the wrong way!

Nonetheless, we learnt a lot from the investigation and this post pulls together some things we learnt. We also found that testing behaviour on browser close is hard, so we’ve included an example we used to do this. In short, this is the post we wish we’d read at the outset!

There are two tricky questions to answer:

  1. How do we make the XHR request?
  2. How do we know the window has been closed?

We will discuss each separately and finish with some examples.

How do we make the XHR request?

We looked at two options:

  1. Beacon API
  2. Native browser fetch with the keepalive flag

The Beacon API works in all browsers except Internet Explorer. Requests are guaranteed to be initiated before a page is unloaded and will run to completion without blocking unload of the page. By default, the Beacon API makes a POST request with a Content-Type header of text/html.

As we wanted to make a GraphQL request we needed to change the Content-Type to application/json for the GraphQL server to accept the request. To do this we use a Blob and set the type property to the content type. In our Beacon request this looks like:

navigator.sendBeacon('http://localhost:8080', new Blob([JSON.stringify({ query: 'mutation { printMessage(message: "hello") }' })], { type: 'application/json' }));

This corresponds to the GraphQL request:

mutation print {
  printMessage(message: "hello")
}

The side effect of changing the Content-Type header to application/json is that it triggers a ‘complex’ CORS request from the Beacon API. It sets the credentials mode to include and sends a CORS pre-flight OPTIONS request before the POST. The server receiving the request needs to return the appropriate set of CORS headers: Access-Control-Allow-Credentials, Access-Control-Allow-Origin, Access-Control-Allow-Headers.

Content-Type is the only header that can be configured for the Beacon request. The W3 Beacon docs state the following:

The sendBeacon method does not provide ability to customize the request method, provide custom request headers, or change other processing properties of the request and response. Applications that require non-default settings for such requests should use the [FETCH] API with keepalive flag set to true.

Using the browser’s native fetch with the keepalive flag allows the request to outlive the page it’s on, so works when the window is closed. However it does not work in all browsers. keepalive relies on the browser’s native implementation of fetch. Some browsers, such as Firefox, don’t currently (version 85) implement the keepalive flag. We can ascertain if keepalive is supported by the browser by evaluating the following in the browser console:

Boolean('keepalive' in new Request(''))

In most cases the Beacon API is the better option to use as it is more widely supported.

Limitations

Both requests only support sending up to 64KB of data. Additionally, both requests are fire-and-forget. We can’t guarantee (and have no way of knowing) that the receiving server successfully handled our request. This means it’s not the right solution for sending critical data.

How do we know that the window has been closed?

MDN recommends using the pagevisibility api as the most reliable way to determine a browser window has closed. Most modern browsers, with the exception of Safari, fire the visibilitychange event when the page is closed.

To be compatible with Safari we also need to attach to the window’s pagehide event. Other browsers fire pagehide too, but it’s not reliably fired on mobile platforms so we have to handle both events. Closing the window is not the only time these events are fired; they are also fired when the user changes tabs, changes active application, or goes back in the browser. For this reason it makes sense to either:

We will assume that the server will handle duplicate requests in our example. Our code will look something like this:

function onUnload(e) {
    if (e.type === 'pagehide') {
      // make XHR request
    }
    if (e.type === 'visibilitychange' && document.visibilityState === 'hidden') {
      // make XHR request
    }
}

document.addEventListener("visibilitychange", onUnload);
window.addEventListener("pagehide", onUnload)

Testing

Testing things that should happen on browser close is difficult. We will step through what we tried, showing both Beacon and fetch requests in each case.

The simplest way to test is to run a netcat server that will listen for XHR requests. This can be done from the terminal with:

while true; do echo \
"HTTP/1.1 200 OK\nAccess-Control-Allow-Origin: *\nAccess-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With" \
| nc -l 8000; done

Now we can set up some requests from the browser console. We used Chrome and kept it simple by only binding to visibilitychange. For each request we should see the type (POST), and the body (“hello”) logged in the terminal when the browser window is closed. Using the Beacon API:

document.addEventListener("visibilitychange", function() {
    if (document.visibilityState === 'hidden') {
        navigator.sendBeacon('http://localhost:8000', 'hello')
    }
})

Using fetch:

document.addEventListener("visibilitychange", function() {
    if (document.visibilityState === 'hidden') {
        fetch('http://localhost:8000', { keepalive: true, method: 'POST', body: 'hello', mode: 'no-cors' })
    }
})

To make sure our code would work with sending GraphQL requests we created an application to accept a GraphQL request and log it to the console.

const express = require('express');
const { graphqlHTTP } = require('express-graphql');
const { buildSchema } = require('graphql');
const cors = require('cors');

const app = express();

const printMessage = (message) => {
    console.log('Got message: ' + message);
}

const schema = buildSchema(`
    type Query {
        message: String
    }

    type Mutation {
        printMessage(message: String): String
    }
`);

const rootValue = {
    message: 'hello world',
    printMessage: ({ message }) => {
        printMessage(message);
        return 'OK';
    }
};

// configure cors requests from localhost
app.use(cors({ origin: /.*localhost.*/, credentials: true }));

app.use((req, res, next) => {
    console.log(`Received request with method: ${req.method}`);
    next();
})

app.use(graphqlHTTP({
    schema,
    rootValue,
    graphiql: true,
}));

app.listen(8080, () => console.log("Server started on port 8080"));

We can call this server from the browser with a GraphQL request like the following (run from http://localhost:8080/ to avoid CORS errors). Using the Beacon API:

document.addEventListener("visibilitychange", function() {
    if (document.visibilityState === 'hidden') {
        navigator.sendBeacon(
            'http://localhost:8080',
            new Blob(
                [JSON.stringify({ query: 'mutation { printMessage(message: "hello") }' })],
                { type: 'application/json' }))
    }
})

Using fetch:

document.addEventListener("visibilitychange", function() {
    if (document.visibilityState === 'hidden') {
      fetch(
          'http://localhost:8080',
          {
              keepalive: true,
              method: 'POST',
              headers: { 'Content-Type': 'application/json'},
              body: JSON.stringify({ query: 'mutation { printMessage(message: "hello") }' })
          })
    }
})

To test if this would work across different browsers we created a React application and set up handlers for visibilitychange and pagehide. When these are called we make two requests: one with the Beacon API, one with fetch. We created the application using create-react-app and added the following to App.jsx:

import React, { useEffect } from 'react';
import { ApolloClient, ApolloProvider, gql, InMemoryCache } from '@apollo/client'
import { createHttpLink } from 'apollo-link-http';

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: createHttpLink({
    uri: 'http://localhost:8080',
    fetchOptions: { keepalive: true }
  }),
});

const printMessage = gql`
  mutation printMessage($message: String!) {
    printMessage(message: $message)
  }
`;

const App = () => {
  function sendRequest(message) {
    // send request using browser fetch
    client.mutate({
      mutation: printMessage,
      variables: {
        message: `FETCH: ${message}`
      },
    })

    // send request using beacon
    const query = { query: `mutation { printMessage(message: "BEACON: ${message}") }` };
    const blob = new Blob([JSON.stringify(query)], { type: 'application/json' })
    navigator.sendBeacon('http://localhost:8080', blob);
  }

  function onUnload(e) {
    if (e.type === 'pagehide') {
      sendRequest('pageHide ' + new Date().toISOString())
    }
    if (e.type === 'visibilitychange' && document.visibilityState === 'hidden') {
      sendRequest('visibilitychange ' + new Date().toISOString())
    }
  }

  useEffect(() => {
    document.addEventListener("visibilitychange", onUnload);
    window.addEventListener("pagehide", onUnload)
    return () => {
      document.removeEventListener("visibilitychange", onUnload);
      window.removeEventListener("pagehide", onUnload)
    }
  });

  return (
    <ApolloProvider client={client}>
      <div className="App">
        Hello world!
      </div>
    </ApolloProvider>
  );
}

export default App;

Note: here we wrap fetch with an Apollo Client as that’s what we use in production. It would also work by using the fetch command above directly in the component, or using another request library (as long as it supports the keepalive flag).

Running these two applications locally allowed us to easily test behaviour on browser close. It also showed that the sendRequest function was being called a lot. If we were to use this in production we would likely want to ensure we only make the request if the data we were sending had changed!

View the code on github: