Fetch keepalive vs sendBeacon: Limits, CORS and Failures

In the fetch keepalive vs sendBeacon choice, use navigator.sendBeacon() for fire-and-forget POSTs with a text or form body, and fetch() with keepalive: true when you need custom headers, another HTTP method or the response. Both outlive the page, and both draw on one 64 KiB in-flight budget.

The behaviour below comes from the specs and from our own runs in November 2026: Playwright 1.61.1 driving Chromium 149, Firefox 151 and WebKit 26.5 against a local Node server. Those are desktop builds on a loopback connection. We did not test Safari itself, a real phone or a killed mobile app. The code samples ran there too, with fetchLater() in Chromium only and the collector on Express 5.3.0.

Fetch keepalive vs sendBeacon: which should you use?

The Beacon spec defines sendBeacon() as a fetch with the keepalive flag set, so the two share a transport and differ in what they let you control.

sendBeacon()fetch() with keepalive
MethodPOST onlyAny
HeadersContent-Type from the body typeYours
Resulttrue if queuedA Response, while the page lives
CookiesAlways includedSame-origin by default
Over budgetReturns falseRejects with a TypeError
First supportChrome 39, Firefox 31, Safari 11.1Chrome 66, Firefox 133, Safari 13

The support row is from browser-compat-data 8.1.8. Check Firefox 133 against your audience, because sendBeacon() reaches much further back there.

A bearer token forces the choice, since a beacon cannot set Authorization. Saving a draft on the way out looks like this:

export async function saveDraft(token, draft) {
  const response = await fetch('/api/draft', {
    method: 'PUT',
    keepalive: true,
    headers: {
      'content-type': 'application/json',
      authorization: `Bearer ${token}`,
    },
    body: JSON.stringify(draft),
  });
  if (!response.ok) throw new Error(`Draft save failed: ${response.status}`);
}

Once the page has gone, nothing is left to read that response or catch that error.

What happens to a plain fetch when the page closes?

Sometimes it arrives, so a quick manual test can pass while another engine loses the data. We sent a 1 KB POST from visibilitychange and from pagehide, then either moved to another page or closed the tab through Playwright, five runs per combination.

Keepalive and beacon requests arrived 5 out of 5 times in every combination, on all three engines. Plain fetch() arrived every time in Chromium. Firefox lost it on every close and delivered it on every page change. WebKit did the reverse: nothing on a page change, and three to five out of five on close.

With a cross-origin JSON POST and an OPTIONS response held back for 1.5 seconds, the plain request never reached the server on any engine. The keepalive version waited out the preflight and sent its POST 5 out of 5 times, after the page had gone.

Send from visibilitychange when the state becomes hidden. The Beacon spec calls it the only event “guaranteed to fire on mobile devices” when a page is backgrounded or unloaded, and MDN’s sendBeacon page gives the same advice, though MDN’s deferred fetch guide says it still has issues on mobile. Avoid unload, which can also cost you the back/forward cache.

Hidden does not mean gone. Someone switching tabs fires the same event and may come back, so send what has accumulated since the last send and never treat a hidden page as the final report.

What does the 64 KiB limit actually count?

Bytes in flight across every keepalive request from the page. The Fetch spec adds the new body to the bodies of unfinished keepalive requests and returns a network error if the sum is “greater than 64 kibibytes”.

A 65,536-byte body went through on every engine. One byte more got false from sendBeacon() in Chromium and WebKit, and a rejection from fetch() everywhere: Failed to fetch in Chromium, NetworkError when attempting to fetch resource. in Firefox and Load failed in WebKit. A CORS failure produced the same messages, so the error alone will not tell you which one you hit.

Firefox’s sendBeacon() is the outlier. It returned true and delivered 65,537 bytes, and also an 80,000-byte body, so a suite that only runs there will not catch an oversized beacon.

It counts bytes, not characters: 40,000 copies of e-acute are 80,000 bytes of UTF-8, and Chromium and WebKit refused them.

Unfinished requests count even after the server has the whole body. We posted 40 KB with keepalive to an endpoint that read the body and then sat on the response for four seconds. During that wait a second 40 KB keepalive fetch was rejected on all three engines, a 40 KB beacon got false in Chromium and WebKit, and a 1 KB beacon still fitted. A plain 40 KB fetch was unaffected. A slow collector holds budget until it responds.

A small queue copes with both problems. It measures bytes and caps each batch below the limit. It also sends early, so a long session never has one huge batch waiting, and it keeps whatever the browser refuses:

const MAX_BATCH_BYTES = 60 * 1024;
const FLUSH_AT_EVENTS = 50;
const encoder = new TextEncoder();
let queue = [];

function takeBatch() {
  const batch = [];
  let bytes = 2; // the array's brackets
  for (const event of queue) {
    const size = encoder.encode(JSON.stringify(event)).length + 1;
    if (bytes + size > MAX_BATCH_BYTES) break;
    batch.push(event);
    bytes += size;
  }
  return batch;
}

function flush() {
  while (queue.length > 0) {
    const batch = takeBatch();
    if (batch.length === 0) {
      queue.shift(); // one event is too big for any batch, so drop it
      continue;
    }
    if (!navigator.sendBeacon('/collect', JSON.stringify(batch))) return;
    queue = queue.slice(batch.length);
  }
}

export function track(event) {
  queue.push(event);
  if (queue.length >= FLUSH_AT_EVENTS) flush();
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});

Refused events wait for the next hidden, so they are lost if the visitor never comes back.

Why does sendBeacon return true when nothing arrives?

Because true means queued. Nothing about delivery comes back to your code.

The case we reproduced is a cross-origin Blob typed application/json. A Content-Type outside the CORS-safelisted values switches the beacon to cors mode, and the spec sets its credentials mode to include. All three engines returned true and sent an OPTIONS, and the POST followed only when the server replied with the exact origin plus Access-Control-Allow-Credentials: true. With no CORS headers, with Access-Control-Allow-Origin: *, or with the exact origin but no credentials header, the server saw the preflight and nothing else.

Compat data still notes that Chrome 59 onwards cannot send such a Blob, yet Chromium 149 sent it, preflight and all.

A keepalive fetch with the same JSON body and Access-Control-Allow-Origin: * got through on all three, because its default credentials mode is same-origin. For a third-party collector that does not want cookies, that is the cleaner choice. A beacon with a string body skips the preflight altogether, but it does carry cookies. Ours arrived at a different port on the same host, the only cross-origin setup we tried; SameSite rules apply beyond that.

The string route has its own trap on the server. A string body arrives as text/plain;charset=UTF-8, and express.json() only parses application/json by default. In Express 5.3.0 our handler got req.body === undefined and still answered 200, so the browser and an access log both saw success. Name the type, and reply before doing any work so the request stops holding budget:

import express from 'express';

const app = express();
const received = [];

app.post(
  '/collect',
  express.json({ type: ['application/json', 'text/plain'], limit: '64kb' }),
  (req, res) => {
    res.status(204).end();
    if (Array.isArray(req.body)) received.push(...req.body);
  },
);

app.listen(8787);

Swap received for your own storage. Any site can post a text/plain body to this route without a preflight, so treat it as we did for Shopify web vitals telemetry: untrusted input, parsed defensively.

Is fetchLater ready to replace both?

Not yet. As of November 2026, compat data marks fetchLater() experimental, in Chrome and Edge from 135, with no support in Firefox or Safari.

It does address what MDN’s guide calls the more difficult part, deciding when to send. You register the request up front and replace it as the data changes. The browser sends it when the page goes away or when activateAfter runs out:

let pending;

export function scheduleReport(metrics) {
  if (!('fetchLater' in window)) return false;
  pending?.abort();
  pending = new AbortController();
  try {
    fetchLater('/collect', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify([metrics]),
      signal: pending.signal,
      activateAfter: 60_000,
    });
    return true;
  } catch (error) {
    if (error.name === 'QuotaExceededError') return false;
    throw error;
  }
}

In Chromium 149 the aborted version never left the browser and only the replacement arrived. The spec’s total request length counts the URL, referrer and headers along with the body. A 70,000-byte body threw QuotaExceededError quoting 70,078 bytes against a limit of “less than 65536 bytes”. Two 30 KB deferred requests fitted and the third threw, which matches MDN’s 64 KiB per reporting origin. MDN adds that http:// URLs throw a TypeError and that the response is ignored. When scheduleReport() returns false, fall back to the queue.

What would we ship?

For same-origin analytics, the beacon queue above with a string body and a collector that answers 204 straight away. Use keepalive when you need a token, a PUT or a cookie-free cross-origin request. Add fetchLater() only where you want a single final snapshot and can afford a beacon fallback.

Do not use any of them for a write the visitor must be able to trust. An order, a payment or a form submission needs a response the page can show, so keep the visitor there until the server has answered.

Whoooop does website speed optimisation work, and telemetry sent this way is one way to see which real pages are slow.

Need this built properly?

Get in touch