Why Last-Minute HTTP Requests Fail
Firing off an HTTP request right as a user navigates away or submits a form is a common need for analytics and logging. A typical implementation might attach a POST request to a link's click event, letting the default navigation proceed while the request is dispatched in the background:
<a href="https://css-tricks.com/some-other-page" id="link">Go to Page</a>
<script>
document.getElementById('link').addEventListener('click', (e) => {
fetch("/log", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
some: "data"
})
});
});
</script>
This appears straightforward, but it masks a critical flaw: browsers don't guarantee that in-flight HTTP requests survive page termination. According to the Page Lifecycle API documentation, once a page begins unloading, the browser clears it from memory. No new tasks can start, and in-progress tasks may be killed if they run too long. The browser simply assumes that when a page is dismissed, background processes queued by it no longer need attention.
Witnessing the Cancellation
To observe this behavior in practice, consider a small Express app with a page that sends a POST request on a link click before navigating to /other. With the Network tab open and a "Slow 3G" connection speed, the request queue looks idle before interaction:

As soon as the link is clicked, however, the pending request is cancelled during navigation:

The same cancellation occurs when navigation is triggered programmatically via window.location:
document.getElementById('link').addEventListener('click', (e) => {
+ e.preventDefault();
// Request is queued, but cancelled as soon as navigation occurs.
fetch("/log", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
some: 'data'
}),
});
+ window.location = e.target.href;
});
In every case, the root cause is the same: XHR requests via fetch or XMLHttpRequest are asynchronous and non-blocking. The browser hands off the request's work to a background API, and when the page terminates, it may abandon that work entirely. This presents a serious reliability problem if you depend on those logs for business decisions.
Blocking Until Completion Is Not the Answer
A tempting fix might be to delay the user's action until the request returns a response. Historically, developers attempted this with XMLHttpRequest's synchronous flag, but that approach blocks the main thread entirely, causes severe performance issues, and has been removed from Chrome v80+. Waiting for a Promise to resolve is a better pattern:
document.getElementById('link').addEventListener('click', async (e) => {
e.preventDefault();
// Wait for response to come back...
await fetch("/log", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
some: 'data'
}),
});
// ...and THEN navigate away.
window.location = e.target.href;
});
Yet this strategy has two significant drawbacks. First, it degrades the user experience by introducing latency from an external dependency into the user's action. Second, it can't cover all termination scenarios — e.preventDefault() does nothing to stop a user from closing a browser tab, so the request still gets dropped.
Preserving Requests with keepalive
The keepalive flag on fetch() addresses this problem natively. Setting it to true keeps the request open even after the initiating page is terminated:
<a href="https://css-tricks.com/some-other-page" id="link">Go to Page</a>
<script>
document.getElementById('link').addEventListener('click', (e) => {
fetch("/log", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
some: "data"
}),
keepalive: true
});
});
</script>
With this change, clicking the link and navigating doesn't cancel the request:

The request shows an (unknown) status because the page never waits for a response — but the request itself is preserved for the receiving service.
Prioritizing with sendBeacon()
The Navigator.sendBeacon() function is purpose-built for one-way requests. A basic implementation sends a POST with stringified JSON and a "text/plain" Content-Type:
navigator.sendBeacon('/log', JSON.stringify({
some: "data"
}));
The API doesn't accept custom headers, so to send "application/json" data you'd need to wrap it in a Blob:
<a href="https://css-tricks.com/some-other-page" id="link">Go to Page</a>
<script>
document.getElementById('link').addEventListener('click', (e) => {
const blob = new Blob([JSON.stringify({ some: "data" })], { type: 'application/json; charset=UTF-8' });
navigator.sendBeacon('/log', blob));
});
</script>
Both approaches achieve the same end result — a request that survives navigation. But sendBeacon() has an operational advantage: beacons are transmitted with low priority. Comparing both simultaneously in the Network tab shows fetch() with keepalive at "High" priority, while the beacon ("ping" type) runs at "Lowest":

Per the Beacon specification, this minimizes resource contention with time-critical operations while still ensuring the request is processed and delivered. For non-critical analytics traffic, this is ideal behavior.
The ping Attribute Option
An increasing number of browsers support the ping attribute on anchor links, which fires a small POST request without any JavaScript:
<a href="http://localhost:3000/other" ping="http://localhost:3000/log">
Go to Other Page
</a>
The request headers automatically include ping-from (the page URL) and ping-to (the link's href):
headers: {
'ping-from': 'http://localhost:3000/',
'ping-to': 'http://localhost:3000/other'
'content-type': 'text/ping'
// ...other headers
},
However, ping has notable limitations:
- It only works on links. Button clicks, form submissions, and other interactions aren't covered.
- Browser support isn't universal. Firefox doesn't enable it by default.
- No custom data can be sent. You're limited to the generated
ping-*headers.
Choosing the Right Tool
Deciding between fetch() with keepalive and sendBeacon() depends on your specific requirements.
fetch() + keepalive may suit you if:
- You need to send custom headers easily.
- You need a
GETrequest rather than aPOST. - You're supporting older browsers (like IE) and already load a
fetchpolyfill.
sendBeacon() might be the better choice if:
- Your requests are simple and don't need customization.
- You prefer a cleaner, more focused API.
- You want to guarantee your requests don't compete with higher-priority traffic.
These reliability issues have real consequences. A sudden ~30% drop in analytics logs can occur after moving request dispatch to a form's submission moment — an abrupt change directly attributable to requests being cancelled during page termination. Understanding how browsers handle these requests and choosing the appropriate preservation mechanism prevents precisely that kind of data loss.



