Sending performance data
Get data out of the page reliably without slowing it down or losing the last beacon. Four ways to send, three events to send on, and two ways to leave, in Chrome, Firefox and WebKit, with every request the server got.
- 01Send reports that arriveHand a report to the browser with
sendBeacon()or a keepalivefetch(), and keep it under the shared 64 KiB limit. - 02Send at the right momentSend when the page becomes hidden, and explain why
unloadandpagehidemiss some visits. - 03Send less, and safelyTrim and batch reports, sample whole visits, and keep personal data out of what you send.
The last report is the hardest
Asha is reading about mint tea at Leaf and Kettle. She clicks Add to cart. Then she closes the tab, and she's gone.
In The web-vitals library, that was the moment two of her numbers became final: INP and CLS. The library handed them to the shop's code as the page was hidden. The shop's code sent them to the shop's server. But the tab was closing at that very moment. Did the numbers get out? Can a page send anything while it's being closed?
Here is what got out when the course closed a tab at that same moment, in three browsers:
/collect when the course closed a tab the same way. Shaded: arrived. Crossed out: lost. The page's own fetch got out only in Chrome. sendBeacon and keepalive hand the report to the browser, which finishes it after the page has gone.It depends on how the page sends them. A plain fetch() got out only in Chrome. The two ways that hand them to the browser got out in all three.
Two words first. A report is one small message with a metric in it, like {"name":"LCP","value":1234}. The server address that receives reports is the collector. Here it's /collect, on the shop's own site.
To find out, the course built a test page that does one thing. When the page is hidden, it sends one report:
const body = JSON.stringify({ name: "LCP", value: 1234 }); addEventListener("visibilitychange", () => { if (document.visibilityState !== "hidden") return; fetch("/collect", { method: "POST", body });});- Line 1 writes the report.
JSON.stringify()turns an object into text, so it can travel. - Line 3 listens for
visibilitychange. The browser fires this event when the page is hidden, or shown again. - Line 4 stops there unless the page is now hidden.
- Line 5 sends the report with a plain
fetch(). APOSTrequest is one that carries data to the server.
The real page also put a test id in the report, so the collector could tell the tests apart. Then the course changed two lines:
- Line 5, the way of sending. It tried four:
navigator.sendBeacon(), which lesson 12.1 used;fetch()withkeepalive: true; the plainfetch()above; and an image. Asking the collector for an image is a request too, so it hears about it. MDN lists that among the old tricks for sending data as a page closes. - Line 3, the event. It tried
visibilitychange,pagehideandunload. The third section explains each one.
It left each page in two ways: by following a link to another page, and by closing the tab. It did all of this in Chrome, and in Playwright's builds of Firefox and WebKit, Safari's engine. Playwright is a tool that drives a real browser. The collector noted every report that reached it. It waited 300 ms before it answered each one, like a server far away.
Here is everything the collector got. Pick a browser and a way of leaving. Start with Chrome, then try WebKit:
Every report arrived, whichever way it was sent and whichever event sent it.
In Chrome, every report arrived. In WebKit, one way of sending lost its report every time: the plain fetch(). Why would a report get lost at all?
Every request a page starts belongs to that page. When the page goes away, the browser stops the requests it hasn't finished. So a plain fetch() only counts if the report reached the collector before that.
The Fetch standard, the rulebook for requests, makes one exception. A request marked keepalive isn't stopped. The browser finishes it after the page has gone. sendBeacon() always sets that mark. With fetch(), you set it yourself.
That's the split in the first picture: the page's own fetch on the top row, and the two handed to the browser below it.
You need to post a letter on your way out of town. At the post office counter, the clerk deals with it while you wait, then hands you a receipt. If your bus comes and you run before she has finished, your letter sits on her desk. It may never go. Or you can drop the sealed letter in the postbox by the door. You don't wait for anything. The post office collects it after you've gone.
- In real life: Your letter
- In JavaScript: The report: one metric, as text
- In real life: Waiting at the counter while the clerk deals with it
- In JavaScript: A plain
fetch(): the page's own request, done when the answer comes back - In real life: Walking out before she has finished
- In JavaScript: The page going away while its fetch is unfinished: the browser stops it
- In real life: Dropping it in the postbox on your way out
- In JavaScript:
sendBeacon(), orfetch()withkeepalive: handed to the browser - In real life: The post office collecting it after you've gone
- In JavaScript: The browser finishing the request after the page has gone
Lesson 12.1 called sendBeacon() a sealed envelope you post as you walk out of the door. Now you know why it still arrives: the browser, not the page, carries it the rest of the way.
A request the page starts can die with the page. Hand the report to the browser instead, with sendBeacon() or fetch() with keepalive. Send it when the page becomes hidden. Keep it small, and leave out anything private.
The rest of the lesson takes these one at a time:
- How to hand a report to the browser, and how much the browser will take.
- When to send it: which event.
- How to send less: batching and sampling.
- What travels with a report that you didn't write.
sendBeacon and fetch with keepalive
sendBeacon: post it and walk away
navigator.sendBeacon() is made for this job. MDN says it's "intended for analytics and diagnostics code to send data to a server." You give it the collector's address and the report:
const body = JSON.stringify({ name: "LCP", value: 1234 });const queued = navigator.sendBeacon("/collect", body);if (!queued) console.log("The browser refused the report.");- Line 1 writes the same report as before.
- Line 2 hands it to the browser.
sendBeacon()gives backtrueorfalsestraight away. - Line 3 checks.
falsemeans the browser refused the report, so it won't be sent.
What does true mean, then? The Beacon spec says it means the browser could "successfully queue the data for transfer." Queued, not delivered. The spec adds that the method "does not provide any information whether the data transfer has succeeded or not." A postbox works the same way: it takes your letter, but it can't tell you when it arrives.
A beacon is always a POST request. The spec lets the browser hold a beacon back for a moment, to save energy and network use. But when the page becomes hidden, it must send every waiting beacon at once.
fetch with keepalive: the same idea, more choices
sendBeacon() has no options. The Beacon spec says it can't change the request's method or add your own headers. For those, the spec points you to fetch() with keepalive set to true:
const body = JSON.stringify({ name: "LCP", value: 1234 });fetch("/collect", { method: "POST", body, keepalive: true,});- Line 1 writes the report.
- Lines 2 to 6 send it with
fetch(), as aPOST, with the report as the request's body. - Line 5 is the one that matters. MDN says that with it, "the browser will not abort the associated request if the page that initiated it is unloaded before the request is complete."
To abort is to stop. A page is unloaded when the browser throws it away. So this request outlives the page, just like a beacon. Firefox added keepalive later than the other browsers, in version 133:
Which one should you use? Read each row as a question, and compare the two answers:
| Question | sendBeacon() | fetch() with keepalive |
|---|---|---|
| Does it outlive the page? | Yes. In the recording, 6 of 6 reports arrived. | Yes. In the recording, 6 of 6 reports arrived. |
| Which method does it use? | Always POST. | The one you choose. POST is the usual one for reports. |
| Can you add your own headers? | No. The Beacon spec leaves that out on purpose. | Yes, any you need. |
| Does it send cookies? | Yes. The spec sets its credentials mode to include. | To your own origin, by default. The credentials option lets you choose. |
| What do you get back? | true or false, at once: queued or refused. | A promise of the server's answer, if the page is still there to read it. |
| How much can it carry? | 64 KiB, shared with the page's other keepalive requests on their way. | The same 64 KiB, shared the same way. |
For a small report to your own collector, either one works. The web-vitals README uses sendBeacon(). Reach for fetch() when you need a choice the beacon doesn't give you.
What reached the collector
Now the recording. Here is what arrived when each page sent its report on visibilitychange:
// Recorded: which reports reached the server. [sendBeacon, keepalive, plain fetch, image]const arrived = { "Chrome, link": [1, 1, 1, 1], "Chrome, close": [1, 1, 1, 1], "Firefox, link": [1, 1, 1, 1], "Firefox, close": [1, 1, 0, 0], "WebKit, link": [1, 1, 0, 1], "WebKit, close": [1, 1, 0, 1],}; const methods = ["sendBeacon", "fetch and keepalive", "a plain fetch", "an image"];methods.forEach((method, i) => { const got = Object.values(arrived).filter((row) => row[i] === 1).length; console.log(`With ${method}, ${got} of ${Object.keys(arrived).length} reports arrived.`);});- Line 1 is a comment. It names the four ways of sending, in the order each row lists them.
- Lines 3 to 8 are recorded. Each row is one browser and one way of leaving. A
1means the report arrived, and a0means it was lost. - Lines 11 to 15 count the 1s for each way of sending, and print one sentence each.
- They print
With sendBeacon, 6 of 6 reports arrived.andWith fetch and keepalive, 6 of 6 reports arrived.ThenWith a plain fetch, 3 of 6 reports arrived.andWith an image, 5 of 6 reports arrived.
Read the picture one column at a time:
- sendBeacon and keepalive arrived every time, in every browser. Both hand the report to the browser.
- The plain fetch arrived in Chrome both times. Firefox lost it when the tab closed. WebKit lost it both ways. So a plain fetch can look fine when you test in Chrome, and still lose reports in other browsers.
- The image lost one report: in Firefox, when the tab closed. MDN lists the image as an old trick that holds up leaving the page. That makes the next page seem slow, though it's the old page's fault.
The postbox slot: 64 KiB
A beacon can't carry just anything. MDN says: "The total size of queued data is limited to 64 KiB (65,536 bytes)." A KiB, or kibibyte, is 1,024 bytes. In a report's text, each plain letter or digit takes one byte.
A postbox has a slot. A letter goes through. A thick parcel doesn't, and you're left holding it. And if the box is already full of letters waiting for the van, even a thin letter won't fit. It has to wait until the van has emptied the box.
- In real life: The slot in the postbox
- In JavaScript: The 64 KiB limit
- In real life: Letters in the box, waiting for the van
- In JavaScript: The page's keepalive requests still on their way
- In real life: A letter that won't go in
- In JavaScript:
sendBeacon()gives backfalse, or a keepalivefetch()fails - In real life: The van emptying the box
- In JavaScript: A request finishing, which frees its bytes
The Fetch standard sets the rule. Before a keepalive request starts, the browser adds up the bodies of the page's keepalive requests that are still on their way. If that sum, plus the new body, is more than 64 KiB, the new request fails. The Beacon spec says beacons and keepalive fetches share this one limit.
The course tested it. It sent a 60,000-byte beacon to a collector that took 2 seconds to answer, so the beacon stayed on its way. Then it sent 10,000 bytes more:
// Recorded: 60000 bytes by sendBeacon, then 10000 more while the first was still on its way.const queued = { Chrome: [true, false], Firefox: [true, true], WebKit: [true, false],}; for (const [engine, [first, second]] of Object.entries(queued)) { console.log(`${engine}: the first beacon was ${first ? "queued" : "refused"}, and the second was ${second ? "queued" : "refused"}.`);}- Line 1 says what was sent. 60,000 bytes fit under the limit. 60,000 and 10,000 together don't.
- Lines 3 to 5 are recorded: what
sendBeacon()gave back each time. - Line 9 prints
Chrome: the first beacon was queued, and the second was refused.ThenFirefox: the first beacon was queued, and the second was queued.andWebKit: the first beacon was queued, and the second was refused.
Chrome and WebKit refused the second beacon, though it was small. The box was nearly full. Next, the course tried 70,000 bytes, over the limit on its own. It tried them twice, by sendBeacon() and by a keepalive fetch():
// Recorded: 70000 bytes, more than 64 KiB, sent by sendBeacon and then by fetch with keepalive.const tooBig = { Chrome: [false, "TypeError"], Firefox: [true, "TypeError"], WebKit: [false, "TypeError"],}; for (const [engine, [beacon, keepalive]] of Object.entries(tooBig)) { console.log(`${engine}: sendBeacon gave back ${beacon}, and fetch failed with a ${keepalive}.`);}- Line 1 says what was sent.
- Lines 3 to 5 are recorded: what
sendBeacon()gave back, and howfetch()failed. - Line 9 prints
Chrome: sendBeacon gave back false, and fetch failed with a TypeError.ThenFirefox: sendBeacon gave back true, and fetch failed with a TypeError.andWebKit: sendBeacon gave back false, and fetch failed with a TypeError.
Every keepalive fetch() of 70,000 bytes failed, and none reached the collector. But Firefox queued and delivered all three beacons, 140,000 bytes in all. Don't count on that. Keep each report small, far under 64 KiB, and check what sendBeacon() gives back.
visibilitychange and pagehide
The way of sending is settled. Now, when? A page gets several events as a visit ends. These are the three the course tried:
visibilitychangefires when the page is hidden, or shown again. MDN lists the ways a page gets hidden. The visitor goes to another page, switches tabs, or closes the tab. Or they minimize or close the browser, or switch to another app on a phone.document.visibilityStatethen says"hidden".pagehidefires when the visitor leaves the page, reloads it, or closes the window. Chrome's guide says it doesn't fire when you only switch tabs.unloadfires when the browser throws the page away. MDN's page on it shows sending analytics fromunloadas the thing to avoid.
In the recording, all three fired every time, and each one delivered the same reports. So does the event matter? It does, for two reasons. The recording couldn't show the first one, because Playwright only followed links and closed tabs. It could show the second.
Some visitors never reach the till
Leaf and Kettle wants a feedback card from every customer. At first, the manager collected the cards at the till, as people paid on their way out. But some customers never come back to the till. They step outside to take a call, and then they just go home. So she moved the box to the door. Everyone passes the door when they step out, even someone who comes back five minutes later.
- In real life: Collecting cards at the till, as people pay and leave
- In JavaScript: Sending on
unloadorpagehide - In real life: Customers who step out and never come back to the till
- In JavaScript: A phone that closes the browser later, with no
pagehideorunload - In real life: The box by the door, which everyone passes
- In JavaScript:
visibilitychange, when the page becomes hidden - In real life: Stepping out to take a call, then coming back
- In JavaScript: Switching tabs and back: hidden, then visible again
Phones work the same way. On a phone, people can switch to another app instead of closing the tab. Later, the phone closes the browser to save memory, or the person closes it from the phone's app manager. MDN says unload and pagehide don't fire then. The page was hidden when its visitor switched apps, and that was its last event.
The Beacon spec puts it plainly. It calls visibilitychange "the only event that is guaranteed to fire on mobile devices when the page transitions to background state". Background state means hidden. This is also why web-vitals reports INP and CLS when the page is hidden, as lesson 12.1 showed.
visibilitychange fired every time.Hidden doesn't always mean gone. A visitor can switch tabs and come back, so a page can be hidden more than once. Send what you have each time. The next section shows how.
unload costs you the back/forward cache
Lesson 12.1 met the back/forward cache, or bfcache. When you leave a page, the browser can keep it whole in memory. Press Back, and the page is shown again at once, like Asha's table, still as she left it when she came back for her scarf.
Chrome's guide to the unload event says it "has a negative impact on a site's performance by preventing the usage of bfcache". The same guide says "pagehide does not make a page ineligible for the back/forward cache." Ineligible means not allowed in. The course tested both in Chrome. Each page sent a beacon from one listener. Playwright left the page, then pressed Back:
// Recorded in Chrome: a page with one listener that sends a beacon. Leave it, then go back.const pages = { unload: { beaconArrived: true, restored: false }, pagehide: { beaconArrived: true, restored: true },}; for (const [listener, { beaconArrived, restored }] of Object.entries(pages)) { console.log(`${listener}: beacon ${beaconArrived ? "arrived" : "lost"}, ${restored ? "restored from" : "not restored from"} the back/forward cache`);}- Line 1 is a comment: what each page did.
- Lines 3 and 4 are recorded in Chrome, with its back/forward cache switched on.
- Line 8 prints
unload: beacon arrived, not restored from the back/forward cacheandpagehide: beacon arrived, restored from the back/forward cache.
Both beacons arrived. But the page with an unload listener was loaded again from the start when Playwright pressed Back. The pagehide page came straight back. So one unload listener, even one that only sends a report, can make Back slower for every visitor.
One surprise. Chrome's guide says Chrome had switched unload off for every site by Chrome 155: "The unload event will no longer fire by default." Yet the unload beacon arrived in Chrome 156, started by Playwright. It arrived again in a Chrome started on its own, with a new profile. Either way, unload lets you down. If it doesn't fire, the report is lost. If it does, the page can lose its back/forward cache.
So which event should you send on? MDN's page on pagehide puts them in order: "The best event to use to signal the end of a user's session is the visibilitychange event. In browsers that don't support visibilitychange the pagehide event is the next-best alternative." Chrome, Firefox and Safari all have visibilitychange:
Batching and sampling
Reports now arrive, at the right moment. The next question is how much to send. web-vitals measures five metrics: TTFB, FCP, LCP, INP and CLS. Each report could go in its own beacon, and each could carry the whole metric. That costs bytes and requests you don't need.
Send only the fields you need
In lesson 12.1, each metric came as an object with fields such as name, value, id and entries. The entries are the browser's raw timing records. TTFB's holds the whole navigation entry. The web-vitals README warns that JSON.stringify "will likely include more data than you need."
The course put web-vitals on a small mint tea page. Playwright loaded it, clicked Add to cart, and followed a link away. The page sent its reports two ways:
// Recorded in Chrome: web-vitals' reports from one visit, each in its own beacon. [requests, bytes]const sent = { "the whole metric": [5, 3917], "three fields": [5, 315],}; for (const [what, [requests, bytes]] of Object.entries(sent)) { console.log(`Sending ${what}: ${requests} requests, ${bytes} bytes.`);}- Line 1 says what was recorded: one beacon per report, in Chrome.
- Line 3 is the whole metric, turned into text with
JSON.stringify(metric). Line 4 is three fields only:name,valueandid. - Line 8 prints
Sending the whole metric: 5 requests, 3917 bytes.andSending three fields: 5 requests, 315 bytes.
Three fields took about a twelfth of the bytes. Sending the whole metric is like photocopying your whole notebook when a friend asked for three numbers. Keep the id, though: lesson 12.1 used it to put a metric's reports together.
One envelope, not five
During the day, you write five short notes for the same friend. You could post each one as you finish it: five envelopes, five stamps, five trips to the postbox. Or you could keep the notes in your bag, and post them all in one envelope on your way home.
- In real life: Each note
- In JavaScript: One metric's report
- In real life: Five envelopes, five stamps, five trips to the postbox
- In JavaScript: Five requests
- In real life: Keeping the notes in your bag through the day
- In JavaScript: The queue: a
Setof reports - In real life: One envelope, posted on your way home
- In JavaScript: One beacon with all of them, sent when the page is hidden
The web-vitals README suggests the same. It says you can "minimize your network usage by batching multiple metric reports together in a single network request." A batch is several reports sent together.
But the metrics become final at different times, so you can't wait until all five are ready. The README's answer: keep a queue, a list of reports waiting their turn. Then send all of it, and empty it, "whenever the page is backgrounded or unloaded." The course's batching page did this:
const queue = new Set(); function addToQueue(metric) { queue.add({ name: metric.name, value: metric.value, id: metric.id });} addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden" && queue.size > 0) { navigator.sendBeacon("/collect", JSON.stringify([...queue])); queue.clear(); }});- Line 1 makes the queue. A
Setis a list that holds each item once. - Lines 3 to 5 are the callback you give each web-vitals function, like
onLCP(addToQueue). It adds three fields of the metric to the queue. It sends nothing. - Line 7 listens for
visibilitychange. Line 8 goes on only if the page is hidden and the queue isn't empty. - Line 9 sends the whole queue in one beacon, as one JSON array.
- Line 10 empties the queue. If the visitor comes back, new reports start a new batch.
Here is what Chrome sent with the batch, against one beacon per report:
// Recorded in Chrome: the same visit's reports, three fields each. [requests, bytes]const sent = { "one beacon per report": [5, 315], "one batch when hidden": [1, 338],}; for (const [how, [requests, bytes]] of Object.entries(sent)) { console.log(`${how}: ${requests} ${requests === 1 ? "request" : "requests"}, ${bytes} bytes`);}- Lines 3 and 4 are recorded in Chrome: the requests and bytes the collector got.
- Line 8 prints
one beacon per report: 5 requests, 315 bytesandone batch when hidden: 1 request, 338 bytes.
Five requests became one, for almost the same number of bytes. That's the README's point: less use of the visitor's network. Here are all three ways side by side:
Firefox and WebKit have no CLS, as lesson 12.1 found. So they had fewer reports, and the batch was one request there too.
Sampling: keep whole visits
A busy shop gets many visits. You may not need reports from all of them. Keeping reports from only some visits is called sampling. The share you keep is the rate: a rate of 0.1 keeps one visit in ten.
Decide once per visit, not once per report. Otherwise you could keep a visit's LCP and drop its INP, and that visit's numbers wouldn't line up. It's like asking every tenth table in the café for feedback: you hear from the whole table, not from one person at each.
// Keep 10% of visits: decide once per visit from its id, so all its reports are kept or dropped together.const RATE = 0.1;function sampled(visitId) { let hash = 0; for (const char of visitId) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; return hash % 1000 < RATE * 1000;} const visits = Array.from({ length: 10000 }, (_, i) => `visit-${i}`);console.log(`kept ${visits.filter(sampled).length} of ${visits.length} visits`);console.log(`visit-42: ${sampled("visit-42") ? "kept" : "dropped"}, and the same on every report`);- Line 1 is a comment. Line 2 sets the rate.
- Lines 3 to 7 decide from the visit's id. Line 5 turns the id into a number, called a hash. The same id always gives the same hash.
- Line 6 takes the hash out of 1,000, with
% 1000. It keeps the visit if that number is under 100, a tenth of 1,000. - Line 9 makes 10,000 visit ids. Line 10 prints
kept 1038 of 10000 visits. - Line 11 prints
visit-42: kept, and the same on every report. Every report fromvisit-42carries the same id, so each one gets the same answer.
The hash kept 1,038 visits, close to a tenth. Make the visit's id once, when the page loads, and put it in every report. When you count visits later, remember the rate: with 0.1, each kept visit stands for ten.
Privacy and consent
Asha's reports should say how fast the page felt. They shouldn't say who she is. But a request carries more than the body your code wrote.
You write three lines on a card and post it. The person who opens it learns more than your three lines. Your return address is on the back. The postmark shows where you posted it from. You never wrote either of them, but they came with the card.
- In real life: The three lines you wrote
- In JavaScript: The report in the beacon's body
- In real life: Your return address on the back
- In JavaScript: The shop's cookie, added by the browser
- In real life: The postmark: where it was posted from
- In JavaScript: The
Refererheader: the address of the page that sent it
The course tested this too. Its privacy page set a cookie, session=abc123. A cookie is a small piece of text a site stores in the browser. The browser sends it back with requests to that site.
The page was opened with an email address in its own address, in the query string: the part after the ?. Then it sent one beacon to its own site, and one to another site. Each collector noted what came with its beacon:
// Recorded in all three engines: what came with a beacon to the shop's own site, and to another site.// {site} is the shop's address. The page's own address had an email in its query string.const ownSite = { referer: "{site}/privacy?email=ana%40example.com", cookie: "session=abc123" };const otherSite = { referer: "{site}/", cookie: null }; console.log(`Own site: Referer ${ownSite.referer}, Cookie ${ownSite.cookie ?? "none"}`);console.log(`Other site: Referer ${otherSite.referer}, Cookie ${otherSite.cookie ?? "none"}`);- Lines 1 and 2 are comments.
{site}stands for the shop's own address. - Lines 3 and 4 are recorded. They were the same in all three browsers.
- Line 6 prints
Own site: Referer {site}/privacy?email=ana%40example.com, Cookie session=abc123. - Line 7 prints
Other site: Referer {site}/, Cookie none.
%40 is how an address writes the @ sign. Two things came along that no code put in the report:
- The Referer header says which page sent the request. HTTP spells it with one r. The shop's own site got the full address, email and all. The other site got only the shop's address. That's the browser's default rule, which MDN calls
strict-origin-when-cross-origin. - The cookie. The Beacon spec sends every beacon with its credentials mode set to
include, so the shop's cookie went to the shop's collector. The other site got none, because the cookie belongs to the shop's site.
So the shop's collector got the visitor's email address, though no report asked for it. Anything you store, you have to keep safe. Three habits help.
First, report the page without its query string. If you put the page in your reports, keep only the parts you need:
const pageAddress = new URL("https://shop.test/privacy?email=ana%40example.com");
const pageToReport = pageAddress.origin + pageAddress.pathname;
console.log(`Page to report: ${pageToReport}`);- Line 1 reads a page's address with
new URL(), which splits it into parts. - Line 2 keeps two parts. The
originis the scheme and the site. Thepathnameis the page's path. The query string is left out. - Line 3 prints
Page to report: https://shop.test/privacy.
In a real page, you'd write location.origin + location.pathname.
Second, leave the cookie and the Referer off when your collector doesn't need them. sendBeacon() has no options for this. A keepalive fetch() does:
const body = JSON.stringify({ name: "LCP", value: 1234 });fetch("/collect", { method: "POST", body, keepalive: true, credentials: "omit", referrerPolicy: "no-referrer",});- Lines 2 to 8 are the keepalive fetch from before.
- Line 6 sets
credentialsto"omit". MDN says that with it, the browser never sends credentials with the request. Cookies are credentials. - Line 7 sets
referrerPolicyto"no-referrer". MDN says the Referer header "will be omitted".
Third, ask before you measure, where you must. Where your privacy policy or the law asks for consent first, keep reports in the queue until the visitor agrees. If they say no, drop them. And store as little as you can.
Keep it or fix it?
Here are eight ways pages send their reports, all from this lesson. Sort each one: keep it as it is, or fix it. Each answer says why.
navigator.sendBeacon()when the page becomes hiddenfetch()withkeepalive: true, carrying a few hundred bytes- Queue the reports, and send one batch when the page is hidden
- Decide sampling once per visit, from the visit's id
- A plain
fetch()when the page is hidden - Sending from an
unloadlistener JSON.stringify(metric)for every report- Reporting the page's full address, query string and all
Sort each way of sending: keep it, or fix it.
Common mix-ups
- "A fetch I start always gets out." No. A plain fetch belongs to the page, so it can be stopped as the page goes. WebKit lost it both ways, and Firefox lost it when the tab closed.
- "When sendBeacon() gives back true, the report has arrived." No. It means the browser queued the report. The spec says the method doesn't tell you whether the transfer succeeded.
- "unload is where you send." No. On phones it often doesn't fire. When it fired in Chrome, it kept the page out of the back/forward cache.
- "pagehide is as safe as visibilitychange." No.
pagehidekeeps the back/forward cache, but MDN says browsers don't fire it reliably, especially on phones. Send onvisibilitychange. - "The 64 KiB limit is for each beacon." No. It's shared by all the page's keepalive requests on their way. Chrome and WebKit refused a 10,000-byte beacon while 60,000 bytes were still on their way.
- "A beacon only carries its body." No. It carried the shop's cookie, and the page's full address in the Referer header, email and all.
Practice exercises
Answer first, then open the hints.
This is the arrivals example with the "link" rows taken out. What does its third line print?
// Recorded: which reports reached the server. [sendBeacon, keepalive, plain fetch, image]
const arrived = {
"Chrome, close": [1, 1, 1, 1],
"Firefox, close": [1, 1, 0, 0],
"WebKit, close": [1, 1, 0, 1],
};
const methods = ["sendBeacon", "fetch and keepalive", "a plain fetch", "an image"];
methods.forEach((method, i) => {
const got = Object.values(arrived).filter((row) => row[i] === 1).length;
console.log(`With ${method}, ${got} of ${Object.keys(arrived).length} reports arrived.`);
});With a plain fetch, 1 of 3 reports arrived. Without the link rows, three rows are left. Only Chrome's has a 1 in the third place.
The sampling example now keeps 20% of visits. What does its first line print?
// Keep 10% of visits: decide once per visit from its id, so all its reports are kept or dropped together.
const RATE = 0.2;
function sampled(visitId) {
let hash = 0;
for (const char of visitId) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
return hash % 1000 < RATE * 1000;
}
const visits = Array.from({ length: 10000 }, (_, i) => `visit-${i}`);
console.log(`kept ${visits.filter(sampled).length} of ${visits.length} visits`);
console.log(`visit-42: ${sampled("visit-42") ? "kept" : "dropped"}, and the same on every report`);kept 2156 of 10000 visits. A visit is now kept when its hash, out of 1,000, is under 200 instead of 100. The hash isn't perfectly even, so it's close to a fifth, not exactly.
When the batching page sent one beacon per report, how many requests did Firefox make? Work it out from what you know about Firefox and CLS.
4. One request per report, and Firefox had no CLS to send. Its batch, like Chrome's, was 1 request.
In the playground, pick WebKit and close the tab. The fetch column doesn't say "lost". What does it say?
Cut off. The collector saw the request start, but its body never finished arriving: WebKit stopped it part-way as the tab closed. When WebKit followed a link, the plain fetch never reached the collector at all, so the grid says lost.
Chrome's batch was 338 bytes. Say none of a page's batches had finished. How many could be on their way at once, before the next one is refused? Predict what the code prints.
const LIMIT = 64 * 1024;
const batch = 338;
let onTheirWay = 0;
let batches = 0;
while (onTheirWay + batch <= LIMIT) {
onTheirWay += batch;
batches += 1;
}
console.log(`${batches} batches fit, using ${onTheirWay} bytes.`);193 batches fit, using 65234 bytes. 193 batches make 65,234 bytes. One more would make 65,572, which is more than 65,536. The Fetch standard refuses a keepalive request only when the total goes over 64 KiB, so the loop uses <=.
This page sends its queue as the visitor leaves. Two things in it can lose the report. Fix both.
const queue = new Set();
addEventListener("unload", () => {
fetch("/collect", { method: "POST", body: JSON.stringify([...queue]) });
});const queue = new Set();
addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden" && queue.size > 0) {
navigator.sendBeacon("/collect", JSON.stringify([...queue]));
queue.clear();
}
});unload may never fire on a phone, and in Chrome it kept the page out of the back/forward cache. A plain fetch() can be stopped as the page goes. visibilitychange fires when the page is hidden, and sendBeacon() is finished by the browser. Clearing the queue means a visitor who comes back starts a new batch.
Check your understanding
For each question, ask four things. Does the request outlive the page? When is it sent? How big is it? What does it carry?
Question 1 of 7What does the third line of the arrivals example print?
Read the code, then predict// Recorded: which reports reached the server. [sendBeacon, keepalive, plain fetch, image] const arrived = { "Chrome, link": [1, 1, 1, 1], "Chrome, close": [1, 1, 1, 1], "Firefox, link": [1, 1, 1, 1], "Firefox, close": [1, 1, 0, 0], "WebKit, link": [1, 1, 0, 1], "WebKit, close": [1, 1, 0, 1], }; const methods = ["sendBeacon", "fetch and keepalive", "a plain fetch", "an image"]; methods.forEach((method, i) => { const got = Object.values(arrived).filter((row) => row[i] === 1).length; console.log(`With ${method}, ${got} of ${Object.keys(arrived).length} reports arrived.`); });Choose an answer to see the explanation.
Question 2 of 7What does the last line of the batching example print?
Read the code, then predict// Recorded in Chrome: the same visit's reports, three fields each. [requests, bytes] const sent = { "one beacon per report": [5, 315], "one batch when hidden": [1, 338], }; for (const [how, [requests, bytes]] of Object.entries(sent)) { console.log(`${how}: ${requests} ${requests === 1 ? "request" : "requests"}, ${bytes} bytes`); }Choose an answer to see the explanation.
Question 3 of 7Why hand the last report to
sendBeacon()or a keepalivefetch(), instead of a plainfetch()?Choose an answer to see the explanation.
Question 4 of 7What does it mean when
sendBeacon()gives backtrue?Choose an answer to see the explanation.
Question 5 of 7On a phone, a visitor switches to another app. Later, the browser is closed from the phone's app manager. Which event did the page get?
Choose an answer to see the explanation.
Question 6 of 7In Chrome, a page with an
unloadlistener and a page with apagehidelistener were each left, then gone back to. What happened?Choose an answer to see the explanation.
Question 7 of 7A beacon to the shop's own site carries which of these, without your code adding them?
Choose an answer to see the explanation.
Key takeaways
- A plain
fetch()belongs to the page and can die with it.sendBeacon()andfetch()withkeepaliveare handed to the browser, which finishes them. sendBeacon()giving backtruemeans queued, not delivered.falsemeans refused.- All the page's keepalive requests on their way share 64 KiB. Keep reports small.
- Send when the page becomes hidden, on
visibilitychange.pagehidecomes next. Avoidunload: it may not fire, and in Chrome it cost the page its back/forward cache. - Send three fields, not the whole metric. Queue the reports, and send one batch when the page is hidden. Sample whole visits.
- Requests carry cookies and the Referer header. Leave out query strings and personal data, and wait for consent where it's needed.
Remember the one-liner.
One small batch, handed to the browser when the page is hidden.
Sources
The facts on this page come from these specs and docs:
- Fetch Standard: the keepalive flag, WHATWG.
- Navigator: sendBeacon() method, MDN.
- Beacon, W3C.
- RequestInit: keepalive, credentials and referrerPolicy, MDN.
- Document: visibilitychange event, MDN.
- Window: pagehide event, MDN.
- Deprecating the unload event, Chrome for Developers.
- web-vitals 6.2.3 README, Google Chrome.
- Referrer-Policy header, MDN.
Coming next: Building a RUM pipeline. Your reports now arrive. Next, you'll build what they arrive at: an endpoint, storage, percentiles and alerts, by hand.