TL;DR, Quick Answer
7 min readThe gap between sendBeacon vs fetch keepalive is control, not delivery. navigator.sendBeacon() queues a POST that outlives the page and hands back only true or false, with a Content-Type the browser derives from the body type you pass. fetch() with keepalive set to true adds custom headers, other methods and the response, and both spend from the same 64 kibibyte in-flight budget defined in the WHATWG Fetch standard.
What is the difference between sendBeacon vs fetch keepalive?
The working difference between sendBeacon vs fetch keepalive is control: navigator.sendBeacon() queues a POST that outlives the page but returns nothing except true or false, while fetch() with keepalive: true lets you choose the method, set custom headers and read the server response. The W3C Beacon specification is explicit: "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 set to true." Both run the same fetch machinery underneath, so one size limit governs both.
How much data can sendBeacon vs fetch keepalive send?
A keepalive request carries at most 64 kibibytes of body, or 65,536 bytes. The WHATWG Fetch standard enforces the cap inside HTTP-network-or-cache fetch, before the request reaches the network:
If contentLength is non-null and httpRequest's keepalive is true, then:
- Let inflightKeepaliveBytes be 0.
- Let group be httpRequest's client's fetch group.
- Let inflightRecords be the set of fetch records in group whose request's keepalive is true and done flag is unset.
- For each fetchRecord of inflightRecords:
- Let inflightRequest be fetchRecord's request.
- Increment inflightKeepaliveBytes by inflightRequest's body's length.
- If the sum of contentLength and inflightKeepaliveBytes is greater than 64 kibibytes, then return a network error.
The spec's note explains the number: "The above limit ensures that requests that are allowed to outlive the environment settings object and contain a body, have a bounded size and are not allowed to stay alive indefinitely." Measure the serialized payload in bytes, not characters, before trusting it to fit.
Is the 64 KiB cap shared across in-flight keepalive requests?
The cap is shared, not per call. Step 5 above sums contentLength with inflightKeepaliveBytes, the total body length of every unfinished keepalive request in the fetch group. The Beacon specification says the same from its side: "Requests initiated via the Beacon API automatically set the keepalive flag, and developers can similarly set the same flag manually when using the Fetch API. All requests with this flag set share the same in-flight quota restrictions that is enforced within the Fetch API." A sendBeacon() call and a fetch(..., { keepalive: true }) call compete for one budget.
The headroom left for your next call is:
remaining bytes = 65536 - (sum of body lengths of unfinished keepalive requests)
Say a 12,288 byte error report and an 8,192 byte click batch are both in flight. Headroom is 65,536 minus 20,480, or 45,056 bytes. A 50,000 byte session summary pushes the sum to 70,480, so fetch() returns a network error and sendBeacon() returns false. Batch every event tracking payload into one flush and that arithmetic stops biting.
What Content-Type does sendBeacon set for each body type?
sendBeacon never lets you set Content-Type yourself: the browser derives it from the body type you pass, and that value decides whether the request stays in no-cors mode or triggers a CORS preflight. The Beacon specification's processing model spells out the branch:
If contentType is not null:
- Set corsMode to "cors".
- If contentType value is a CORS-safelisted request-header value for the
Content-Typeheader, set corsMode to "no-cors".- Append a
Content-Typeheader with value contentType to headerList.
The Fetch standard defines the safelist test for that header: "If mimeType's essence is not application/x-www-form-urlencoded, multipart/form-data, or text/plain, then return false." Fetch also fixes the Content-Type each body type produces:
| Body you pass to sendBeacon | Content-Type the browser sets | On the CORS safelist | Cross-origin outcome |
|---|---|---|---|
| ArrayBuffer, TypedArray, DataView | none | no header sent | no-cors, no preflight |
| String | text/plain;charset=UTF-8 | Yes | no-cors, no preflight |
| URLSearchParams | application/x-www-form-urlencoded;charset=UTF-8 | Yes | no-cors, no preflight |
| FormData | multipart/form-data; boundary=... | Yes | no-cors, no preflight |
Blob with type application/json | application/json | No | CORS mode, preflight required |
The Beacon specification states the consequence: "If the request does not contain a payload, or the request Content-Type is a CORS-safelisted request-header, then the request mode is no-cors." Otherwise, in the spec's words, "a CORS preflight is made and the server needs to first allow such requests by returning the appropriate set of CORS headers: Access-Control-Allow-Credentials, Access-Control-Allow-Origin, Access-Control-Allow-Headers." A preflight doubles the round trips on a page that is already unloading, so send JSON as a plain string and parse it server side.
![]()
Why should a beacon fire on pagehide instead of unload?
Fire the beacon on visibilitychange first and pagehide second, and leave unload alone, because unload breaks the back/forward cache. MDN's sendBeacon page states it plainly: "the unload event is incompatible with the back/forward cache (bfcache) implemented in modern browsers. Some browsers, such as Firefox, handle this incompatibility by excluding pages from the bfcache if they contain unload handlers, thus hurting performance." MDN recommends pagehide as the fallback for browsers without visibilitychange: "Like beforeunload and unload, this event is not reliably fired, especially on mobile. However, it is compatible with the bfcache."
The Beacon specification adds the mobile reason: "Developers should avoid relying on unload event because it will not fire whenever a page is in a background state (i.e. visibilityState equal to hidden) and the process is terminated by the mobile OS." The full trade-off between the exit events lives in beforeunload vs pagehide.
Flowsery
Start Your 14-Day Free Trial
Real-time dashboard
Goal tracking
Cookie-free tracking
How do you write a sendBeacon call that survives unload?
Pass a plain string, check the boolean return, and clear the buffer only once the browser accepts the queue:
const endpoint = "https://collect.example.com/session";
const buffer = { sessionId: crypto.randomUUID(), events: [] };
function flush() {
if (buffer.events.length === 0) return;
const body = JSON.stringify(buffer);
if (navigator.sendBeacon(endpoint, body)) {
buffer.events.length = 0;
}
}
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flush();
});
window.addEventListener("pagehide", flush);The string body produces text/plain;charset=UTF-8, which is on the safelist, so the cross-origin POST skips the preflight. A false return means the payload plus everything in flight crossed 65,536 bytes, and the buffer survives for the next attempt.
How do you write the same call with fetch keepalive?
Set keepalive: true, add the headers sendBeacon will not carry, and accept that the promise resolves only if the page survives to see it:
window.addEventListener("pagehide", () => {
fetch("https://collect.example.com/session", {
method: "POST",
keepalive: true,
headers: {
"Content-Type": "application/json",
"X-Api-Key": apiKey,
},
body: JSON.stringify(buffer),
}).catch(() => {});
});Both Content-Type: application/json and the custom X-Api-Key header fall outside the CORS safelist, so a cross-origin call here triggers an OPTIONS preflight and needs Access-Control-Allow-Headers on the server. One restriction applies only to keepalive: the Fetch standard's extract-a-body algorithm throws a TypeError for a ReadableStream body when the flag is set, so streaming out on exit is not an option. That exit moment is why a lightweight script earns its keep, and why session replay performance overhead shows up at the flush, not at the record.
Which one should you use?
Use sendBeacon for fire-and-forget analytics, and fetch keepalive whenever a header, a method or the response matters.
| Requirement | sendBeacon | fetch keepalive |
|---|---|---|
| HTTP method | POST only | Any method |
| Custom request headers | Not supported | Supported |
| Content-Type control | Derived from body type | Set by you |
| Server response | Not available | Read via the promise |
| Body size | Shares the 64 KiB budget | Shares the same budget |
| Available in service workers | No | Yes |
Past 64 kibibytes, neither works: sample the payload, split it, or move the aggregation to the server, a choice covered in client-side vs server-side analytics.
![]()
Frequently asked questions
Does a sendBeacon return value of true mean the server received the data?
No. The Beacon specification says a true return "implies the browser has queued the data for transfer" and that "since the actual data transfer happens asynchronously, this method does not provide any information whether the data transfer has succeeded or not." A false return is the only other signal, and it means the queue rejected the payload.
Can I set an Authorization header on sendBeacon?
No. The Beacon specification states that sendBeacon "does not provide ability to customize the request method, provide custom request headers, or change other processing properties of the request and response." Move the credential into the URL path, a cookie or the body, or switch to fetch() with keepalive: true.
Why does my sendBeacon Blob trigger a CORS preflight?
Because the Blob's type becomes the request's Content-Type, and application/json is not on the CORS safelist. The Fetch standard limits that safelist to application/x-www-form-urlencoded, multipart/form-data and text/plain, so anything else forces an OPTIONS preflight. Send the JSON as a string and the request stays in no-cors.
Is the 64 KiB limit per request or per page?
Per fetch group, across every unfinished keepalive request in it. The Fetch standard sums the new body's contentLength with inflightKeepaliveBytes and returns a network error past 64 kibibytes, so a large beacon still in flight shrinks room for the next one.
Does fetch keepalive work in a service worker?
Yes. MDN lists service worker availability as an advantage of fetch() with keepalive over sendBeacon(), alongside non-POST methods, custom request properties and access to the response.
What happens to a keepalive request if the user closes the tab immediately?
The browser keeps the request alive past the document that started it, which is the purpose of the flag. The Fetch standard describes keepalive as allowing "the request to outlive the environment settings object," and the cap exists so those surviving requests "have a bounded size and are not allowed to stay alive indefinitely." What you lose is the response: the promise has no live page to resolve into.
Can sendBeacon use methods other than POST?
sendBeacon only sends POST, as the comparison table shows. fetch with keepalive accepts any method, so switch to fetch when the endpoint expects PUT, PATCH or GET.
Why does a streaming body fail with fetch keepalive?
The Fetch standard's extract-a-body algorithm throws a TypeError for a ReadableStream body whenever keepalive is set, so a stream cannot leave the page on exit. Serialize the payload with JSON.stringify or pass a string body instead.
Flowsery
Start Your 14-Day Free Trial
Real-time dashboard
Goal tracking
Cookie-free tracking
Should I listen for pagehide and visibilitychange together, or pick one?
Fire on visibilitychange first, since it catches a tab going to the background before the page actually unloads, then add pagehide as the fallback for browsers where visibilitychange does not fire reliably. The flush function in the post's example attaches both listeners and lets whichever fires first send the beacon.
What should I do once my payload passes 64 KiB?
Neither sendBeacon nor fetch keepalive can carry a body past 65,536 bytes; the request returns a network error or a false result once the shared budget is exceeded. Sample the payload, split it across multiple flushes, or move the aggregation to the server.
Was This Article Helpful?
Let us know what you think!
See us more often in Google
One click marks Flowsery as a preferred source, so our articles sit higher in your Top Stories, AI Mode, and AI Overviews.
Before you go...
Flowsery
Revenue-first analytics for your website
Track every visitor, source, and conversion in real time. Simple, powerful, and cookie-free.
Real-time dashboard
Goal tracking
Cookie-free tracking
Related Glossary Terms


Why Beforeunload vs Pagehide Decides If Analytics Survive
Comparing beforeunload vs pagehide shows why mobile skips beforeunload, blocks the back-forward cache, and why pagehide with sendBeacon flushes data reliably.


Why CNAME Cloaking No Longer Hides Trackers From Safari or Brave
Trackers used CNAME cloaking to pose as your subdomain. See how the DNS trick works, why Safari caps its cookies at 7 days and how Brave and uBlock uncloak it.


How Cookie Syncing Matches Ad IDs Across Websites
Ad-tech firms use cookie syncing to swap user IDs through redirects and pixels. See what Safari, Firefox and Chrome do about it and why analytics skips it.


How to Build GA4 Calculated Metrics Inside a Five-Slot Quota
Google caps GA4 calculated metrics at 5 per standard property. Learn the formula syntax, the units, where they appear and what a formula can't use.


Setting Up GA4 Content Grouping With One Parameter
Set up GA4 content grouping with the content_group parameter in gtag or Tag Manager, read the Content group dimension and keep (not set) rows out.


What User Agent Client Hints Send, and What Analytics Still Sees
Chrome's user agent client hints split browser data into Sec-CH-UA headers. See low vs high entropy, Accept-CH, the reduced UA and what analytics reads.
Related Articles


Where Rage Clicks vs Dead Clicks Actually Differ
The rage clicks vs dead clicks split is about cause, not severity. Detection thresholds published by PostHog, Hotjar, FullStory, LogRocket and Clarity.


Where PII Redaction Session Replay Defaults Leave Data Exposed
PII redaction session replay defaults vary wildly: rrweb masks only passwords, Sentry masks all text, Clarity masks numbers and emails.


What Unassigned Traffic GA4 Reports Actually Means
A channel with no matching rule, unassigned traffic ga4 shows is not a missing value like (not set). Google's own rules explain the difference.