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.
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:
Output
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:
Failure | sendBeacon | fetch(keepalive) |
|---|---|---|
Bad URL scheme |
|
|
|
|
|
Body over the 64 KiB pool | returns
| promise
rejects with
|
CORS / preflight refused | silent — boolean was already
| promise rejects (in cors mode) |
Server error status | invisible — no response exists | resolves; check
|
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:
Output
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:
falsenow, or aTypeErrorrejection 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
booleanorPromise<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.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.