12.1 Testing beacons, and the complete reference
The Playwright interception fixture the series was validated with, what DevTools can show, and every load-bearing fact on one card with its spec anchor.
Advanced
A fire-and-forget API is an automation paradox: the page code cannot observe its own sends, so the only honest tests live outside the page. This closing page builds the interception fixture the whole series was validated with, maps what DevTools can and cannot show you, and ends with the reference card the rest of the series earns — every table on one page, every number with its source.
The interception fixture
Playwright's
page.route() stands between the browser and the network, which is exactly the vantage point sendBeacon's contract denies your page code. The fixture below is the series' own harness, reduced to its teaching core: route the collector, fulfill with 204 (the spec-encouraged answer), call the real API from a real page, and assert on what actually went on the wire — method, body, and the requests that never arrived:
// Testing beacons with Playwright route interception.
//
// sendBeacon has no response, no promise, no callback — so the only
// way to assert what actually went on the wire is to intercept the
// request OUTSIDE the page. Playwright's page.route() does exactly
// that. Run with: node beacon-fixture.spec.cjs
//
// (This is the one file in the series that is not browser-vanilla
// by nature: it is a Node-side test harness. The page code under
// test — the beacon send itself — is plain vanilla.)
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
// The mock collector: capture and fulfill with 204 (the response
// the Beacon spec's note encourages servers to send):
const seen = [];
await page.route('**/api/collect', (route) => {
seen.push({
method: route.request().method(),
body: route.request().postData() || '',
});
return route.fulfill({ status: 204, body: '' });
});
await page.goto('https://example.com/');
const returned = await page.evaluate(() => {
// The code under test — exactly what a real page ships:
return navigator.sendBeacon(
'https://example.com/api/collect',
JSON.stringify({ event: 'pageview', path: '/' })
);
});
console.log('sendBeacon returned:', returned); // true = queued
console.log('intercepted method :', seen[0].method); // POST
console.log('intercepted body :', seen[0].body);
// -- Quota edge: one byte over 64 KiB cannot be queued -----------
// The spec's §4.6 test is "greater than 64 kibibytes", so exactly
// 65536 body bytes fits an empty pool — and 65537 returns false,
// with the request never reaching the network (route sees it not).
// NB: if another keepalive request is still in flight, its body
// bytes count against the pool too — that sum is what turned
// "65536 fits" into "65536 refused" in earlier sequential tests:
const bigReturned = await page.evaluate(() =>
navigator.sendBeacon(
'https://example.com/api/collect',
'x'.repeat(64 * 1024 + 1)
)
);
console.log('65537-byte body returned:', bigReturned); // false
console.log('total requests intercepted:', seen.length); // 1 — the big one never left
await browser.close();
})().catch((e) => { console.error(e); process.exit(1); });Run it and the output is the whole quota story in four lines: the beacon queued (
true), the interception saw POST and the exact JSON body, the 65,537-byte body returned
false — and the route recorded only
one request, because the refused beacon never touched the network at all. That last assertion is the pattern's real power: absence-of-request is a first-class testable fact, which is precisely what silent-drop bugs hide. The same harness style covers lifecycle timing (dispatch visibility changes inside
page.evaluate and assert the flush order) and service-worker paths (register the worker, route through it, assert the re-issued body). One caveat earned the hard way: sequential beacons interact through the in-flight pool — a still-pending earlier request turns a would-fit size into a refusal — so quota tests should use fresh contexts when they mean to test the boundary itself.
What DevTools shows you
The Network panel has no beacon filter — beacons are fetches, and they appear as ordinary POSTs. What distinguishes them in the tooling is metadata, not a badge: the initiator column and Resource Timing report the
"beacon" initiator type the Fetch standard assigns (the series' part on detection explained why that value is not JS-visible), and the request carries keepalive semantics visible in timing behavior — transmission scheduled at the hidden transition rather than call time. Both transports (sendBeacon and keepalive fetch) appear as script-initiated posts; distinguishing them by eye is unreliable, and distinguishing them in code is the false-positive surface the service-worker page mapped. For assertions, prefer interception over inspection every time.
The complete reference
Fact | Value | Source |
|---|---|---|
|
| |
Return value | true = queued; false = refused — never delivery | |
Fixed recipe | POST, keepalive, credentials include, initiator "beacon", no-cors default | |
TypeErrors | non-http(s)/unparseable URL; ReadableStream body | Beacon + Fetch extraction |
Content-Types | string→text/plain;charset=UTF-8; form→urlencoded; multipart; Blob→its type; BufferSource→none | |
CORS mode | no-cors unless non-safelisted Content-Type → cors + preflight | |
Quota | new body + in-flight keepalive bodies > 64 kibibytes → refused; body bytes only | |
Flush guarantee | MUST on visibilityState → hidden (sendBeacon only) | |
Response | none; server encouraged to answer 204 No Content | |
|
| |
Deferred quota | whole request counts; 64 KiB per reporting origin; 640 KiB per tab (non-normative) |
Quick check
Your Playwright test asserts a beacon fired, but the route never intercepted it. What is the first suspect?
await page.route('**/api/collect', route => route.fulfill({ status: 204 }));
const ok = await page.evaluate(() =>
navigator.sendBeacon('/api/collect', tinyPayload));
// ok === true, but no request was intercepted. Why?
Key takeaways
-
Test beacons from outside the page:
page.route()interception makes method, body, and absence-of-request assertable. -
Fulfill with
204— the spec-encouraged response — to keep fixtures faithful to real collectors. - Quota tests need fresh contexts: in-flight requests interact through the shared pool.
- DevTools has no beacon filter; initiator metadata and timing are the only hints — assertions belong in the fixture.
- The reference card's rows are the series' load-bearing facts, each with its specification anchor for re-verification.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.