Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

9.1 sendBeacon versus fetch(keepalive): the decision and the hybrid layer

One decision function from runtime and capability, the hybrid sender with its discriminated return, and the famous one-line fallback — with the shared-pool trap that makes it a capability answer only.

Levels: Intermediate, Advanced ~17 min read

Advanced

Two transports, one pool, different contracts. Every practical question — which to call, when to fall back, what a failure means — reduces to three rows: the return signal (synchronous boolean versus Promise), the flush guarantee (MUST versus none), and the capability set (fixed recipe versus full RequestInit). This page turns those rows into a decision function, then wraps it in the hybrid layer real codebases ship: one pure chooser, one unified sender with a discriminated return, and the famous one-line fallback — with the shared-pool trap hiding inside the third.

The decision, in one pass

Start from the runtime: if sendBeacon does not exist (any worker global), the decision is already made. Then from the need: custom headers, a non-POST method, or a readable response can only come from fetch(keepalive) — accepting the loss of the flush guarantee. Everything else — plain fire-and-forget telemetry — belongs on sendBeacon, where the guarantee and the synchronous quota signal are free. The runnable example encodes exactly that as a pure function and walks the six cases worth memorizing:

Interactive Runnable example
Edit

Ready to run.

Error modes, side by side

The two transports report failure at different times, through different channels — and misreading which is which is a recurring source of wrong retry logic. The table is short enough to memorize:

Same underlying spec paths, different surfacing — the boolean versus the Promise.

Failure

sendBeacon

fetch(keepalive)

Bad URL scheme

TypeError, synchronously

TypeError, synchronously

ReadableStream body

TypeError, synchronously

TypeError at Request construction

Body over the 64 KiB pool

returns false, synchronously

promise rejects with TypeError (network error)

CORS / preflight refused

silent — boolean was already true

promise rejects (in cors mode)

Server error status

invisible — no response exists

resolves; check response.ok/

Note what the last two rows teach together: the promise is not a delivery guarantee either. It tells you what the network layer observed — a resolved promise with a 500 status, or a rejection for a network error — and nothing about server-side processing. The queueing boolean tells you less, earlier. Neither tells you the data was acted upon; only server-side acknowledgement can close that loop.

The chooser and the sender

pickTransport() is the decision function from the section above, unchanged. hybridSend() wraps it: the sendBeacon path returns the synchronous boolean; the fetch path returns Promise<boolean>. That discriminated union is deliberate — collapsing it to always-Promise would erase the synchronous signal that the last-gasp moment exists for, and collapsing it to always-boolean would lie about the fetch path. Callers check which they hold, and the type itself documents the trade. And because the transports arrive as injectable parameters, the same sender runs in the page, in any worker, and under tests — the isomorphic-sender pattern part two promised, at full strength:

Interactive Runnable example
Edit

Ready to run.

The one-liner, and its shared-pool trap

The web-popularized pattern — navigator.sendBeacon(url, body) || fetch(url, { method: 'POST', body, keepalive: true }) — reads as a graceful fallback and is a fine capability fallback: where sendBeacon is missing (old runtimes, worker-like contexts), the fetch keeps telemetry flowing. As a capacity fallback it is broken by arithmetic: both transports draw from the same 64 KiB in-flight pool, so a false caused by a full pool means the fallback is queueing against the same full pool. It fails quietly — the promise is dropped — and the pattern's return value ( true, optimistic) reports success. Watch the example's final scenario stage exactly this failure, then keep the rule straight:

One more design note from the example: the worker-simulation branch shows the env injection point again — the same dependency-injection discipline the earlier recipes used, applied to the transport layer, making every branch of the hybrid testable without a single real network event. This is the pattern the analytics SDK on the next page inherits wholesale.

Quick check

sendBeacon returned false (pool full). What does the one-liner fallback actually accomplish?

navigator.sendBeacon(url, body) ||
  fetch(url, { method: 'POST', body, keepalive: true });
// result: 

Key takeaways

  • Runtime first (workers have no sendBeacon), then capabilities (headers/method/response force fetch), else sendBeacon.
  • Synchronous boolean versus Promise is a timing difference in failure reporting, not a difference in delivery guarantees.
  • Quota refusal: false now, or a TypeError rejection later — same §4.6 arithmetic underneath.
  • The one-liner is a capability fallback (no sendBeacon here), never a capacity fallback (pool full).
  • chooser as a pure function; sender returns boolean or Promise<boolean> — the union is the contract.
  • The hybrid layer is the spine the analytics SDK composes onto — nothing in it knows about events or batching.
  • At the hidden transition, prefer sendBeacon — the MUST-flush and the sync result are exactly what that moment consumes.
Was this page helpful?

Mark this learning resource complete to track your learning progress.