Mazzarolo MatteoMazzarolo Matteo

Determining if an HTTP request was sent as beacon/keepalive

By Mazzarolo Matteo


At work, we recently worked on a somewhat unusual integration test: we wanted to determine if a web app was successfully sending a network request after the user left the page.

You might ask: why would you ever need to send a network request after the user leaves the page?

Well, one common use case is to fire and forget some data to a service when you don't care about the service response; for example, right before the user navigates to another page. In this situation, the browser may be about to unload the page, and in that case, the browser may choose not to send asynchronous network requests.
In the past, web pages have tried to delay the page unloading long enough to send data using ugly workarounds such as submitting the data with a blocking synchronous XMLHttpRequest call.

Luckily, in the last few years, browsers implemented a few ways to send fire-and-forget network requests more reliably. The two most popular ones are:

So, back to our integration test: our goal was to validate that these APIs were correctly sending HTTP requests after the page closed.
The main complexity in doing so was that the same requests we run as fire-and-forget are often fired as classic HTTP requests in the app lifecycle.
So we needed a way to determine whether an HTTP request was sent as beacon/keepalive.

Here's what we learned:

If you're interested, here's what our Playwright integration test looks like (running on Chromium):

import { test, expect } from "@playwright/test";
 
test("Send an HTTP network request after the user leaves the page ", async ({ page }) => {
  await page.goto("https://localhost");
 
  let didSendTheFireAndForgetRequest = false;
 
  page.on("request", (request) => {
    if (didSendTheFireAndForgetRequest) return;
    const resourceType = request.resourceType();
    const isBeaconRequest = ["beacon", "ping"].includes(request.resourceType());
    const isKeepAliveRequest = !!request.keepalive;
    didSendTheFireAndForgetRequest = isBeaconRequest || isKeepAliveRequest;
  });
 
  // Do something to make the page enqueue the network request...
  await something();
 
  await page.close({ runBeforeUnload: true });
 
  await expect.poll(() => didSendTheFireAndForgetRequest, { timeout: 10000 }).toBe(true);
});
import { test, expect } from "@playwright/test";
 
test("Send an HTTP network request after the user leaves the page ", async ({ page }) => {
  await page.goto("https://localhost");
 
  let didSendTheFireAndForgetRequest = false;
 
  page.on("request", (request) => {
    if (didSendTheFireAndForgetRequest) return;
    const resourceType = request.resourceType();
    const isBeaconRequest = ["beacon", "ping"].includes(request.resourceType());
    const isKeepAliveRequest = !!request.keepalive;
    didSendTheFireAndForgetRequest = isBeaconRequest || isKeepAliveRequest;
  });
 
  // Do something to make the page enqueue the network request...
  await something();
 
  await page.close({ runBeforeUnload: true });
 
  await expect.poll(() => didSendTheFireAndForgetRequest, { timeout: 10000 }).toBe(true);
});