completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

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.

By the end, you can
  • 01
    Send reports that arriveHand a report to the browser with sendBeacon() or a keepalive fetch(), and keep it under the shared 64 KiB limit.
  • 02
    Send at the right momentSend when the page becomes hidden, and explain why unload and pagehide miss some visits.
  • 03
    Send 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:

Asha's page, Leaf and Kettle, as she closes the tab and the page sends INP and CLS. What reached /collect when the course closed a tab and sent its report on visibilitychange: plain fetch, the page's own: Chrome arrived, Firefox lost, WebKit lost; sendBeacon, handed to the browser: Chrome arrived, Firefox arrived, WebKit arrived; keepalive, handed to the browser: Chrome arrived, Firefox arrived, WebKit arrived.Asha closes the tabINP and CLS go outLeaf and KettleAdd to cartINPCLSsent withreached /collect?ChromeFirefoxWebKitplain fetchthe page's ownarrivedlostlostsendBeaconhanded to the browserarrivedarrivedarrivedkeepalivehanded to the browserarrivedarrivedarrived
Asha's tab closing, and what reached /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:

The test page sends one report as it's hiddenJavaScript
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(). A POST request 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() with keepalive: true; the plain fetch() 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, pagehide and unload. 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:

Playground: did the last report arrive?
Sent on each eventDid it reach the collector?
sendBeaconkeepalivefetchimage
visibilitychangearrivedarrivedarrivedarrived
pagehidearrivedarrivedarrivedarrived
unloadarrivedarrivedarrivedarrived
Try it yourself
Chrome, close the tab: 12 of 12 reports arrived

Every report arrived, whichever way it was sent and whichever event sent it.

Each test page sent one report from one listener, to a collector that answered 300 ms later. Chrome 156 ran with its back/forward cache on, as it normally does. Recorded on an Apple M1 Pro.

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.

Real-life analogyA letter in the postbox, or a receipt at the counter

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(), or fetch() with keepalive: 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.

The main idea

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:

  1. How to hand a report to the browser, and how much the browser will take.
  2. When to send it: which event.
  3. How to send less: batching and sampling.
  4. 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:

Hand the report to the browserJavaScript
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 back true or false straight away.
  • Line 3 checks. false means 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:

The same report, by fetch with keepaliveJavaScript
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 a POST, 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:

sendBeacon or fetch with keepalive?
QuestionsendBeacon()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:

Which reports arrivedPop out in the code editor (opens in a new tab)JavaScript
// 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 1 means the report arrived, and a 0 means 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. and With fetch and keepalive, 6 of 6 reports arrived. Then With a plain fetch, 3 of 6 reports arrived. and With an image, 5 of 6 reports arrived.
Reports sent on visibilitychange that reached the server: Chrome, follow a link: sendBeacon, keepalive, fetch, image; Chrome, close the tab: sendBeacon, keepalive, fetch, image; Firefox, follow a link: sendBeacon, keepalive, fetch, image; Firefox, close the tab: sendBeacon, keepalive; WebKit, follow a link: sendBeacon, keepalive, image; WebKit, close the tab: sendBeacon, keepalive, image.sendBeaconkeepalivefetchimageChrome, linkarrivedarrivedarrivedarrivedChrome, closearrivedarrivedarrivedarrivedFirefox, linkarrivedarrivedarrivedarrivedFirefox, closearrivedarrivedlostlostWebKit, linkarrivedarrivedlostarrivedWebKit, closearrivedarrivedlostarrived
One row per browser and way of leaving. Shaded: the report arrived. Crossed out: it was lost. The sendBeacon and keepalive columns are shaded all the way down.

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.

Real-life analogyA postbox slot takes only so much

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 back false, or a keepalive fetch() 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:

A second beacon while the first is on its wayPop out in the code editor (opens in a new tab)JavaScript
// 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. Then Firefox: the first beacon was queued, and the second was queued. and WebKit: 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():

One envelope too thickPop out in the code editor (opens in a new tab)JavaScript
// 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 how fetch() failed.
  • Line 9 prints Chrome: sendBeacon gave back false, and fetch failed with a TypeError. Then Firefox: sendBeacon gave back true, and fetch failed with a TypeError. and WebKit: 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:

  • visibilitychange fires 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.visibilityState then says "hidden".
  • pagehide fires 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.
  • unload fires when the browser throws the page away. MDN's page on it shows sending analytics from unload as 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

Real-life analogyPost your card at the door, not at 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 unload or pagehide
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 pagehide or unload
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.

Which events fired as a visit ended: follow a link: visibilitychange, pagehide, unload; close the tab: visibilitychange, pagehide, unload; switch tabs, come back: visibilitychange; switch apps, closed later: visibilitychange.visibilitychangepagehideunloadfollow a linkfiredfiredfiredclose the tabfiredfiredfiredswitch tabs, come backfirednot firednot firedswitch apps, closed laterfirednot firednot fired
Shaded: the event fired. Crossed out: it didn't. The first two rows are from the recording, in all three browsers. The last two are from MDN and Chrome's guide. Only 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:

unload or pagehide?Pop out in the code editor (opens in a new tab)JavaScript
// 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 cache and pagehide: 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:

The whole metric, or three fieldsPop out in the code editor (opens in a new tab)JavaScript
// 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, value and id.
  • Line 8 prints Sending the whole metric: 5 requests, 3917 bytes. and Sending 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

Real-life analogyFive notes in one envelope

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 Set of 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:

A queue, sent when the page is hiddenJavaScript
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 Set is 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:

One beacon per report, or one batchPop out in the code editor (opens in a new tab)JavaScript
// 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 bytes and one 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:

Chrome's web-vitals reports from one visit, sent three ways: Each report, the whole metric, 5 requests, 3917 bytes; Each report, three fields, 5 requests, 315 bytes; One batch when hidden, 1 requests, 338 bytes.Each report, the whole metric3917 bytes, 5 requestsEach report, three fields315 bytes, 5 requestsOne batch when hidden338 bytes, 1 request
The same visit's reports, sent three ways. Each bar is the bytes the collector got, to scale. The batch is about as small as the trimmed reports, and it took one request instead of five.

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 one visit in tenPop out in the code editor (opens in a new tab)JavaScript
// 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 from visit-42 carries 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.

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.

Keep it or fix it?
  • navigator.sendBeacon() when the page becomes hidden
  • fetch() with keepalive: 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 unload listener
  • JSON.stringify(metric) for every report
  • Reporting the page's full address, query string and all
Try it yourself
0 of 8 correct

Sort each way of sending: keep it, or fix it.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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. pagehide keeps the back/forward cache, but MDN says browsers don't fire it reliably, especially on phones. Send on visibilitychange.
  • "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.

Exercise 1 · Warm-upOnly the closed tabs

This is the arrivals example with the "link" rows taken out. What does its third line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// 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.`);
});

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upA bigger sample

    The sampling example now keeps 20% of visits. What does its first line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // 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`);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFirefox's reports, one by one

      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.

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeWebKit's plain fetch

        In the playground, pick WebKit and close the tab. The fetch column doesn't say "lost". What does it say?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeHow many batches fit?

          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.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          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.`);

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeFix the goodbye

            This page sends its queue as the visitor leaves. Two things in it can lose the report. Fix both.

            Starter codeJavaScript
            const queue = new Set();
            
            addEventListener("unload", () => {
              fetch("/collect", { method: "POST", body: JSON.stringify([...queue]) });
            });

            Answer, then press Check. Spacing and letter case don’t matter.

              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?

              Sending performance data quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the third line of the arrivals example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // 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.

              2. Question 2 of 7What does the last line of the batching example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // 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.

              3. Question 3 of 7Why hand the last report to sendBeacon() or a keepalive fetch(), instead of a plain fetch()?

                Choose an answer to see the explanation.

              4. Question 4 of 7What does it mean when sendBeacon() gives back true?

                Choose an answer to see the explanation.

              5. 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.

              6. Question 6 of 7In Chrome, a page with an unload listener and a page with a pagehide listener were each left, then gone back to. What happened?

                Choose an answer to see the explanation.

              7. 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() and fetch() with keepalive are handed to the browser, which finishes them.
              • sendBeacon() giving back true means queued, not delivered. false means refused.
              • All the page's keepalive requests on their way share 64 KiB. Keep reports small.
              • Send when the page becomes hidden, on visibilitychange. pagehide comes next. Avoid unload: 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:

              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.

              CompleteFrontend Clear concepts. Working examples.