Glossary

Why sendBeacon vs fetch keepalive Is a 64 KiB Decision

Taras Shynkarenko
Taras Shynkarenko
•Updated: •7 min read
Why sendBeacon vs fetch keepalive Is a 64 KiB DecisionWhy sendBeacon vs fetch keepalive Is a 64 KiB Decision

TL;DR, Quick Answer

7 min read

The 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:

  1. Let inflightKeepaliveBytes be 0.
  2. Let group be httpRequest's client's fetch group.
  3. Let inflightRecords be the set of fetch records in group whose request's keepalive is true and done flag is unset.
  4. For each fetchRecord of inflightRecords:
    1. Let inflightRequest be fetchRecord's request.
    2. Increment inflightKeepaliveBytes by inflightRequest's body's length.
  5. 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.

Where the 64 KiB budget goes
Error report12,288 bytes
Click batch8,192 bytes
Both in flight20,480 bytes
Headroom left45,056 bytes
Full cap65,536 bytes
Every unfinished keepalive request in the fetch group draws from the same 65,536-byte cap, so headroom is what the cap has left after the requests already in flight.

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:

  1. Set corsMode to "cors".
  2. If contentType value is a CORS-safelisted request-header value for the Content-Type header, set corsMode to "no-cors".
  3. Append a Content-Type header 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 sendBeaconContent-Type the browser setsOn the CORS safelistCross-origin outcome
ArrayBuffer, TypedArray, DataViewnoneno header sentno-cors, no preflight
Stringtext/plain;charset=UTF-8Yesno-cors, no preflight
URLSearchParamsapplication/x-www-form-urlencoded;charset=UTF-8Yesno-cors, no preflight
FormDatamultipart/form-data; boundary=...Yesno-cors, no preflight
Blob with type application/jsonapplication/jsonNoCORS 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.

A person shutting a laptop lid, the moment a browser tab exits and a beacon must fire.

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
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.

RequirementsendBeaconfetch keepalive
HTTP methodPOST onlyAny method
Custom request headersNot supportedSupported
Content-Type controlDerived from body typeSet by you
Server responseNot availableRead via the promise
Body sizeShares the 64 KiB budgetShares the same budget
Available in service workersNoYes

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.

Network cables plugged into a server rack, representing the endpoint that receives keepalive requests.

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
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

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

Related Articles