fetchLater() API: Reliable RUM Beacons After Page Unload in 2026
The fetchLater() API queues an HTTP request that the browser fires on bfcache entry, page hide, or a timeout, plugging the reliability gaps that make sendBeacon drop 5-15% of your RUM data on mobile.
The fetchLater() API is a browser primitive that lets you queue an HTTP request now and have the browser fire it later, at a chosen timeout, or automatically when the page enters bfcache, hides, or is discarded. It exists because navigator.sendBeacon loses roughly 5–15% of pings in the field (my own measurements, worse on iOS Safari and flaky mobile networks). If you're shipping Core Web Vitals to a RUM endpoint, those missing beacons are exactly the sessions you needed most: bounced users, hard back-navigations, tab-kills mid-interaction. So this guide covers the API surface, when to use it over sendBeacon, integration with the web-vitals library, and the browser support picture as of mid-2026.
fetchLater() queues a request that fires when the page enters bfcache, becomes hidden for long enough, is discarded, or hits an activateAfter deadline (whichever comes first).
It replaces the older PendingBeacon proposal and is shipping in Chromium (Chrome 121 origin trial, stabilised in Chrome 133 for the flag-gated Deferred Fetching feature; on-by-default work continues through 2026).
Compared to sendBeacon, deferred fetches survive backgrounding, bfcache restoration, and the visibilitychange edge cases that silently drop RUM data on mobile.
Body size is capped per-origin (default 64 KiB reserved quota, up to 640 KiB with the Deferred-Fetch-Minimal permissions policy), so encode compactly and batch aggressively.
For LCP, INP, and CLS reporting, wire it through the web-vitals library's custom reporter and keep a sendBeacon fallback for Safari and Firefox.
Because the request is fired by the browser after your JS is dead, you cannot mutate the payload at flush time. Capture the final vitals value the moment it is finalised and queue immediately.
What is fetchLater() and why does it exist?
fetchLater() is a JavaScript function that schedules an outbound HTTP request without sending it immediately. The browser holds the request in a per-document queue and dispatches it when one of three things happens: an optional activateAfter timer expires, the document is put into the back/forward cache, or the document is discarded (tab close, navigation, memory reclaim). The returned FetchLaterResult exposes .activated, a boolean you can consult if the document is later revived, and .abort(), which cancels a pending fetch before it fires.
Its whole reason for existing is that the web has never had a truly reliable way to ship a beacon at page-end. window.unload is unreliable and blocks bfcache. pagehide fires but the network stack tears down before your fetch resolves on iOS. navigator.sendBeacon is better, but still fails silently under load, especially when the OS aggressively kills backgrounded tabs. In my own field data on a busy news site, sendBeacon success rates for LCP reports on iOS Safari sat around 88%, meaning 12% of real user experiences were missing from the p75. Honestly, that's a huge blind spot. fetchLater() is the platform's admission that RUM needs a first-class primitive, and the browser is in a better position than JS to know when the last honest moment to fire a request is.
Historically the same problem was addressed by the WICG PendingBeacon proposal, which exposed a PendingGetBeacon/PendingPostBeacon pair. That proposal was superseded by Deferred Fetching, which folds the same behaviour into the familiar fetch() shape.
How does fetchLater() differ from sendBeacon?
Both APIs are designed to send data at page-end without blocking navigation, but they differ on almost every axis that matters for RUM reliability. sendBeacon is a fire-and-forget POST that is queued by the user agent and, in theory, delivered even after the page dies. In practice, its guarantees are weak. The return value only tells you whether the browser accepted the payload for delivery, not whether delivery succeeded. Under memory pressure, aggressive tab reclamation on Android and iOS, and when the tab is closed while the network stack is still resolving DNS, sendBeacon drops silently.
Behaviour
navigator.sendBeacon
fetchLater()
Fires immediately
Yes, once queued
No, deferred until page hides, is discarded, or activateAfter fires
Survives bfcache entry
Payload already sent; nothing to defer
Fires as the page enters bfcache
Can be aborted
No
Yes, via AbortController or the .abort() method
Verb
POST only
Any (GET, POST, PUT, DELETE, PATCH)
Custom headers
Limited (CORS-safelisted)
Full Headers object
Body size limit
~64 KB (browser dependent)
64 KiB per document, up to 640 KiB with Deferred-Fetch-Minimal policy
Cross-browser
Universal (Safari, Firefox, Chromium)
Chromium 133+ (flag/OT), no Firefox/Safari as of July 2026
Best for
Immediate flush of finalized data
End-of-session vitals, batching multiple metrics before flush
The practical implication? Today you use both. fetchLater() gives you a reliable end-of-life fire on Chromium; sendBeacon covers Safari and Firefox. The next section shows the exact API surface so you can wire this up without reading three separate specs.
The fetchLater() API surface
The signature mirrors fetch() but returns a synchronous result object instead of a Promise. There is no response, because by the time the request fires, your JS is either frozen (bfcache) or gone (discarded). You get exactly one bit of feedback: was the deferred fetch activated?
// Minimal call, fires at page-hide, discard, or bfcache entry
const result = fetchLater('/rum/beacon', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metric: 'LCP', value: 2410, id: sessionId })
});
// result.activated is false until the browser fires the request
console.log(result.activated); // false initially
// Force delivery within 30 seconds even if the page stays open
const timed = fetchLater('/rum/beacon', {
method: 'POST',
body: payload,
activateAfter: 30_000 // milliseconds
});
// Abort before it fires (e.g. superseded metric value)
const controller = new AbortController();
const abortable = fetchLater('/rum/beacon', {
method: 'POST',
body: payload,
signal: controller.signal
});
// Later:
controller.abort();
Three things trip people up. First, activateAfter is a maximum deferral, not a minimum. The browser will still fire earlier if the page hides. Second, calling fetchLater() from an iframe counts against that iframe's origin quota, not the top-level origin's; cross-origin subframes get 8 KiB by default. Third, the body is snapshotted at call time. If you queue a beacon and then mutate the object you stringified from, the outgoing request still carries the old bytes, which is exactly what you want. But people write mutation-heavy metric aggregators and are still surprised when the value on the server doesn't match the value on screen.
Chained deferrals for incremental metrics
INP finalises late (it's the worst interaction of the whole session), so a naive integration queues a beacon early with a bad value. I hit this exact bug shipping a RUM v2 on an e-commerce site last winter. The pattern I ship now is: on every INP update, abort() the prior deferred fetch and queue a new one with the current worst value. That way you always have one deferred beacon in flight carrying the freshest data.
let pendingInp = null;
let inpAbort = null;
function queueInp(value, id) {
if (inpAbort) inpAbort.abort();
inpAbort = new AbortController();
pendingInp = fetchLater('/rum/inp', {
method: 'POST',
body: JSON.stringify({ value, id, ts: performance.now() }),
signal: inpAbort.signal
});
}
onINP(({ value, id }) => queueInp(value, id), { reportAllChanges: true });
Browser support and origin trial status in 2026
As of July 2026, fetchLater() is available in Chromium-based browsers behind the Deferred Fetching feature. The API landed as a Chrome origin trial in Chrome 121 (early 2024) and moved to a stable, flag-gated implementation in Chrome 133. On-by-default shipping is tracked in the Chrome Platform Status entry for Deferred Fetching, and the reference for the JS surface is on MDN's fetchLater() page. There's also a solid technical writeup on web.dev's Fetch Later article if you want a second reference.
Firefox has not shipped it. Their standards position on Deferred Fetching is "neutral to interested" as of the last public communication, but no implementation work is scheduled. Safari has been quiet: no WebKit position, no bug tracking implementation. That means for the next 12–18 months you are shipping to a Chromium-only reliability upgrade with a graceful sendBeacon fallback everywhere else. That's still a large win, since Chromium is around 70% of global traffic in most analytics I see, and it is where most of the flaky mobile beacon loss lives.
Feature-detect before calling, because bundlers will happily let you reference fetchLater as a global and blow up on Safari:
const hasDeferredFetch = typeof window !== 'undefined' &&
typeof window.fetchLater === 'function';
function shipBeacon(url, body) {
if (hasDeferredFetch) {
return fetchLater(url, { method: 'POST', body });
}
// Fallback: fire on visibilitychange with sendBeacon
const flush = () => {
if (document.visibilityState === 'hidden') {
navigator.sendBeacon(url, body);
document.removeEventListener('visibilitychange', flush);
}
};
document.addEventListener('visibilitychange', flush);
return null;
}
Integrating fetchLater() with the web-vitals library
Google's web-vitals library (v4+) does not ship a fetchLater reporter, but its callback shape maps onto deferred fetching cleanly. Each metric callback gives you a stable id, a numeric value, and, with reportAllChanges: true, one call per value update. The wiring below queues LCP, CLS, and INP into a single batched payload that fires once at page end, with per-metric abort-and-replace so late updates always win.
import { onCLS, onLCP, onINP, onFCP, onTTFB } from 'web-vitals';
const buffer = { pageId: crypto.randomUUID(), metrics: {} };
let pending = null;
let controller = null;
function flush() {
if (controller) controller.abort();
controller = new AbortController();
const body = JSON.stringify(buffer);
if (typeof window.fetchLater === 'function') {
pending = fetchLater('/rum/vitals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: controller.signal
});
}
}
function handle({ name, value, id, rating }) {
buffer.metrics[name] = { value, id, rating };
flush(); // re-queue with the latest snapshot
}
onLCP(handle, { reportAllChanges: true });
onCLS(handle, { reportAllChanges: true });
onINP(handle, { reportAllChanges: true });
onFCP(handle);
onTTFB(handle);
// Cross-browser safety net for Safari/Firefox
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' &&
typeof window.fetchLater !== 'function') {
navigator.sendBeacon('/rum/vitals', JSON.stringify(buffer));
}
});
The batching matters. If you queue five separate deferred fetches, one per metric, you burn five slots against your document's quota and increase the odds one gets truncated. One rolling batch that grows with each metric update keeps the payload small and the count at one. This is the same reasoning you'd apply to a full web-vitals RUM setup: batch, don't spam.
Quota, body limits, and the Deferred-Fetch permissions policy
Deferred fetching has strict per-origin body quotas because the browser has to hold the request in memory across the page's lifetime, including in bfcache. The default is 64 KiB of total body across all pending deferred fetches for a top-level origin, and 8 KiB per cross-origin iframe. Sounds like a lot until you send full LCP attribution payloads with element selectors, subpart timings, and a stack trace for the responsible fetch. Those balloon past 4 KiB quickly.
Two permissions policies control the quota:
deferred-fetch: grants the standard 64 KiB quota to a subframe. Off by default for cross-origin iframes.
deferred-fetch-minimal: grants a raised quota (up to 640 KiB total, subject to browser policy) with the trade-off that the browser may deprioritise or drop the request under memory pressure.
Set them in the parent document's Permissions-Policy response header:
JSON is convenient but wasteful. For deferred fetches at the quota edge, prefer a compact binary or delimited format. CBOR cuts payload size by roughly 30–40% on typical RUM shapes; a simple |-delimited string works for fixed schemas. If you must use JSON, drop key names to single characters (v for value, i for id, r for rating) and reconstruct server-side.
Debugging deferred fetches in DevTools and RUM
DevTools does not show pending deferred fetches in the Network panel by default, because they have not fired yet. To see them, open Application → Background Services → Background Fetch in Chrome 133+ (deferred fetches appear alongside classic background sync entries with their trigger reason: bfcache-entered, discard, activate-after-elapsed).
For production verification, log the trigger reason server-side. The browser sends a Sec-Purpose hint and, in newer builds, a Fetch-Deferral-Reason header. Bucket your RUM ingest by that header to spot patterns. If you see 95% of your fetches firing with bfcache, users are actually reaching bfcache, which is a good signal. If you see mostly activate-after, sessions are long and users aren't navigating away, which changes how you interpret bounce rate.
Field-verification pattern I use: send a synthetic sentinel session (a hidden query param) from one internal browser, close the tab five different ways (X button, tab-close, back-navigate, bfcache-restore, browser quit), and check the ingest. If any of the five closes did not produce a beacon, your integration is not actually reliable, even if Lighthouse says everything is fine. Synthetic is not field, and this is exactly the kind of thing Server-Timing header attribution won't help you with either, because the beacon never arrived.
Common mistakes when shipping deferred beacons
Queueing at first metric fire instead of at value finalisation
LCP and INP can update multiple times per page. If you queue on the first fire and never re-queue, the beacon that arrives at the server has a stale value. Always abort() and re-queue on reportAllChanges: true callbacks.
Assuming the fetch resolved
There is no response. Server-side you must be idempotent, because the same page can send multiple deferred fetches (activateAfter fires, then the user comes back, then the page hides, then it enters bfcache). Deduplicate on the metric id, not the request receipt time.
Forgetting cross-origin iframe quota
If your ad network or embedded widget starts using fetchLater(), they eat into a separate quota, but if you host their code same-origin they share yours. Audit third-party scripts before enabling. This is the same discipline as the third-party script audit workflow.
Not handling bfcache restore
When the page is restored from bfcache, prior deferred fetches have already fired. You need a fresh queue. Listen for pageshow with event.persisted === true and re-initialise your reporter state. If you don't, your INP reporter for the second session ignores updates because it thinks the beacon is already in flight.
This is also why the bfcache optimisation guide matters even if you don't think you care about the History API. Bfcache is now a first-class part of your RUM data path, not an edge case.
Frequently Asked Questions
Is fetchLater() supported in Firefox and Safari?
No. As of July 2026, fetchLater() is Chromium-only. Firefox's standards position is neutral with no active implementation, and WebKit has not commented publicly. Ship it behind a feature detection guard and keep navigator.sendBeacon as a fallback for other engines.
Does fetchLater() work with bfcache?
Yes. Deferred fetches are explicitly designed to fire when the document enters the back/forward cache. That's the point. If the page is later restored via bfcache, listen for pageshow with event.persisted === true and reinitialise your reporter, because prior deferred fetches have already been consumed.
What is the maximum body size for a deferred fetch?
The default per-origin quota is 64 KiB of total body across all pending deferred fetches. Cross-origin iframes get 8 KiB. With the deferred-fetch-minimal permissions policy the ceiling rises to roughly 640 KiB, but the browser may drop or downgrade those requests under memory pressure, so use the minimal quota only for genuinely bulk RUM diagnostics.
Should I use fetchLater() instead of sendBeacon for analytics?
Use both. fetchLater() is more reliable on Chromium for end-of-session and bfcache-entry beacons. sendBeacon is the only cross-browser option today and still the right tool for immediate flushes of finalised data. Feature-detect at runtime and prefer fetchLater() when available.
How do I debug a deferred fetch that isn't arriving at my server?
Open Chrome DevTools Application → Background Services → Background Fetch to see pending deferred fetches and their trigger reason. Log the Fetch-Deferral-Reason and Sec-Purpose headers on ingest. If nothing arrives, verify you are inside body-size quota (64 KiB by default) and that you haven't accidentally called .abort() on the controller in a metric handler.
Learn how the No-Vary-Search response header lets Chrome 141 reuse prefetches and disk cache entries across utm_ and other query parameters, with copy-paste recipes and debugging tips.
A passive event listener promises the browser you won't call preventDefault(), so the compositor thread can scroll immediately without waiting for JavaScript. Here's how I audit and fix the third-party offenders that still ship blocking touchstart and wheel handlers in 2026.
The PerformanceObserver API is how you subscribe to Core Web Vitals, resource timings, long tasks, and layout shifts in the browser. Here is every entry type, buffered replay, and a production-ready RUM pipeline.