Every field measurement ends in the same spot: the value is final, but the page is already half gone. Interaction to Next Paint only knows its final value once no further interaction follows, and Cumulative Layout Shift keeps adding up until the last frame. Anyone who actually wants these numbers has to send them at the exact moment when the browser is least inclined to start another network request. The farewell events meant for this are unreliable on mobile: Chrome for Developers writes that the events sometimes fail to arrive at all (Chrome for Developers). With fetchLater() there has been another route since Chrome 135 - the request is registered while the page is healthy, and the browser sends it itself once the document goes away (MDN browser-compat-data). This article explains how the quota is structured, where the limits sit and what a fallback looks like that you can ship with a clear conscience.
Key takeaways
- Farewell events are not a dependable channel. On mobile devices unload often does not run, because tabs move to the background and are killed there (Chrome for Developers).
- fetchLater() reverses the order: the request is registered while the page is healthy, and the browser sends it once the document goes away for good.
- The quota is 640 KiB per tab, of which 512 KiB belong to the top-level document and 128 KiB to embedded cross-origin frames (WHATWG, MDN).
- The interface has shipped in a single browser family so far, from version 135; for everything else the beacon on visibility change remains the fallback (MDN browser-compat-data).
Why the last measurement often fails to arrive
The sequence is the same in almost every measurement script. While the page is open, a PerformanceObserver collects values. At the end a package is supposed to go to your own endpoint. Historically there were two anchors for that: unload and pagehide, later joined by visibilitychange. Chrome for Developers states the problem plainly: both events have difficult corner cases that differ by browser, and sometimes the events fail to arrive at all, especially on mobile (Chrome for Developers). The article on deprecating unload gets even more concrete: on mobile browsers unload often does not run, because tabs are frequently backgrounded and then killed (Chrome for Developers). That is not an edge case but the normal case on the device where most sessions happen.
On top of that, unload is not merely unreliable, it actively gets in the way: a registered handler stops the page from entering the back/forward cache. Its spread is declining accordingly, but it has not disappeared. The Web Almanac 2025 counts unload handlers on 28 percent of desktop pages and 20 percent of mobile pages among the top 1,000 sites; the share declines steadily across lower-ranked sites and reaches 11 percent on desktop and 10 percent on mobile (Web Almanac 2025). So anyone still hanging on this channel measures precisely the sessions poorly that say the most about their own site - the long ones, the mobile ones, the abandoned ones.
The old channel is being switched off on a schedule
What fetchLater() does differently
The idea is plain and therefore sturdy: instead of firing off a request at the moment of disappearance, the page registers the request beforehand. fetchLater() takes the same arguments as fetch - address, method, headers, payload - but sends nothing straight away. The browser remembers the request and runs it when the document is destroyed. That moves responsibility to where it belongs: the browser knows when a tab is being closed, the script does not. The interface was trialled from Chrome 121 in January 2024 in an origin trial with real users, running until 3 September 2024 (Chrome for Developers).
Two properties make the difference in daily work. First, a registered request can be withdrawn before it is sent and replaced by a new one. If you have an updated value for Interaction to Next Paint every few seconds, you do not register ten requests; you withdraw the previous one and register a new one carrying the current state. Second, there is activateAfter: a deadline in milliseconds after which the browser sends the request on its own, even while the page is still alive. That lets you build a heartbeat without maintaining your own timer, which gets throttled in background tabs anyway.
The quota: 640 KiB per tab
A channel that still sends after the page has ended needs a hard ceiling - otherwise it becomes a tool for data exfiltration. The Fetch Standard sets it: the quota for deferred fetching is allocated to a top-level traversable, that is, a tab, and amounts to 640 kibibytes (WHATWG). By default 128 kibibytes of that are reserved for delegating quota to nested documents of foreign origin, each of which reserves 8 kibibytes (WHATWG). MDN describes the same split from the document's point of view: 640 KiB per document, divided into a 512 KiB top-level quota and a shared quota of 128 KiB (MDN). Within the allocated quota, moreover, only 64 kibibytes may be in flight concurrently for the same reporting origin, that is, the origin of the target address (WHATWG).
// Queue a measurement while the page is still alive.
// The browser sends it later on its own - no farewell event required.
let controller = null;
let registration = null;
let payload = null;
function queueMeasurement(data) {
payload = JSON.stringify(data);
if (typeof fetchLater !== 'function') return;
// Withdraw the previous registration while it has not been sent yet.
if (registration && !registration.activated && controller) controller.abort();
controller = new AbortController();
try {
registration = fetchLater('/api/measurements', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
signal: controller.signal,
activateAfter: 300000
});
} catch (error) {
// QuotaExceededError: quota used up - shrink the payload.
registration = null;
}
}
// Fallback for browsers without fetchLater()
if (typeof fetchLater !== 'function') {
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && payload) {
navigator.sendBeacon('/api/measurements', payload);
}
});
}The comparison with the previous route is stark. For keepalive requests the Fetch Standard caps the total: if the sum of the content length and the keepalive bytes already in flight exceeds 64 kibibytes, a network error is returned (WHATWG). That is the ceiling under which navigator.sendBeacon and fetch with keepalive work together. Ten times more room sounds like luxury at first, but it is above all an invitation to stay tidy: a registered request holds memory as long as it is open. If the page lands in the back/forward cache, the registration survives - the Chromium documentation names the lifetime of that cache as the ceiling, currently 10 minutes after the page enters it (Chromium).
Availability and the fallback
Here is the catch, and it belongs at the start of any plan. MDN's compatibility data lists Chrome version 135 as the first shipping version for fetchLater() (MDN browser-compat-data). For Firefox it records no shipping version, with a pointer to the open bug report; for Safari the picture is the same (MDN browser-compat-data). In practice that means a substantial share of mobile sessions still runs without this interface. Anyone evaluating their field data on that basis, without thinking the fallback through, quietly shifts their sample towards one browser family and then gets numbers that say little about their own audience.
Detection instead of assumption
A check for typeof fetchLater === 'function' decides the route. No inference from the browser's user agent string - that string says nothing about this interface, and it is shipped in shortened form in plenty of places anyway.
One event, two routes
The beacon on visibility change stays in place for every other browser. It is registered only when fetchLater() is missing, otherwise the same value would arrive twice and would have to be de-duplicated again during evaluation.
The same payload
Both routes send the same body to the same address. The endpoint sees no difference and needs no second evaluation path for the Core Web Vitals, which keeps the effort down to a handful of lines in the measurement script.
| Property | navigator.sendBeacon | fetchLater() |
|---|---|---|
| Availability | in all current browsers | one browser family so far, from version 135 (MDN browser-compat-data) |
| Ceiling | shared keepalive cap of 64 KiB (WHATWG) | 640 KiB per tab (WHATWG) |
| Trigger | needs a farewell event in the script | the browser sends on its own |
| Correctable | once sent, immutable | registration replaceable until it is sent |
| Time-driven | needs your own timer | activateAfter as a deadline in milliseconds |
What this means for the Core Web Vitals
Interaction to Next Paint is the metric that suffers most from lost closing packages, because its final value is only settled at the end of the session. A value of at most 200 ms counts as good: an INP below or at 200 milliseconds means a page has good responsiveness (web.dev). The Web Almanac 2025 reports an encouraging improvement for mobile pages, with 77 percent of websites achieving good scores, up from 74 percent in 2024 (Web Almanac 2025). Those numbers come from field observation; your own measurement has to reach the same quality, otherwise you compare a clean external figure with a leaky internal one. Outliers can be traced through long animation frames - but only if the package carrying the outlier actually arrives.
The core in one sentence
When foreign frames draw on the same quota
The shared quota is the point where a well-meant embed damages your own measurement. MDN puts it like this: each cross-origin subframe gets an 8 KiB quota out of the shared 128 KiB quota by default, allocated when the subframe is added to the document - regardless of whether fetchLater() is used there at all. This means that, in general, only the first 16 such frames added to a page can use the interface, because they use up the 128 KiB quota (MDN). A page with many embedded videos, maps and ad slots therefore distributes its quota before anyone registers a single request. Anyone who is trimming third-party scripts anyway wins here a second time.
An exhausted quota shows up as an exception
How we proceed in projects
The change is small; the order is what matters. We start with the question of which values only settle at the end of the session at all, and separate them from everything that can be sent earlier. Everything that can be sent earlier is sent earlier - that is the cheapest improvement and needs no new interface. Only the remainder goes into the registration. After that we measure how many packages actually arrive, split by browser family, because otherwise the improvement in one family papers over the gap in another. We use the same cut when we make backend time visible with Server-Timing - check the measurement chain first, interpret the numbers second.
- Separate the values: what settles early, what only at the end? Only the remainder needs a farewell channel.
- Add detection and wire up both routes so that no browser uses both at once.
- Shrink the payload: short field names, no repeated context data, numbers instead of strings.
- Collect the delivery rate per browser family before any number from the new channel goes into a report.
- Touch the limits: remove one cross-origin frame as a test and check whether the registration then goes through.
For the delivery rate you need one field in the package that identifies the session, plus a second, early ping carrying the same identifier. The ratio of the two numbers is the delivery rate, and it is the only sound statement about how much your own measurement actually sees. If you do not know that rate, you do not know what your metrics are worth either. The same holds for applications with soft navigations: there the document rarely ends, but the screen content changes constantly, and attribution needs its own look at soft navigations.
The browser knows when a page ends. The script learns it by luck at best. Move the moment of sending to where the knowledge lives and you no longer depend on luck.
That leaves the question of whether the effort pays off while a single browser family ships the interface. The answer depends on the audience: if that family's share is high, the change lifts the delivery rate noticeably without making anything worse for the rest. If it is low, the change stays cheap regardless, because it costs fewer than fifty lines and the fallback is needed anyway. The only expensive variant is doing both halfway. Anyone who treats delivery as a quantity to be known and maintained will recognise the same attitude in dealing with unused CSS: measure what really happens first, then remove what nobody needs.
Sources and Studies