User Timing and custom metrics
Measure what matters to your product, like the time to the hero image or to the first search result. A tea shop with marks and measures, loaded and searched in Chrome, Firefox and WebKit, against when things really reached the screen.
- 01Choose a custom metricPick a wait that matters on your own site, and write down its name, its start and its end.
- 02Time it with marks and measuresMark moments, measure between them, attach detail, and collect the measures from every visitor.
- 03Make the number tell the truthStart at the user's input and end when the result is on screen, not when your code ran.
- 04See it in DevToolsFind your metrics on the Timings track, or on a track of your own.
Measure what your users wait for
Asha opens the Leaf and Kettle store. She types "mint" in the search box and presses Enter. For a moment, nothing changes. Then three teas appear: Mint tea, Moroccan mint, and Mint and lemon.
The shop's owner has one question for you: how long did Asha wait for those teas?
You know a lot of metrics by now. LCP says when the biggest picture or block of text appeared. CLS adds up how much things jumped. INP times clicks and key presses, up to the next frame the browser draws. They work on every site, and that's their strength. It's also why none of them can answer the owner. The browser doesn't know what "the teas are here" means on your page. Only you do.
The closest is INP. The course timed Asha's search in Chrome and put the two numbers side by side:
// Chrome, Asha's search for "mint". Times in ms from the start of the page.const enterKey = { startTime: 1093.1, duration: 80 }; // Event Timing's entry for Enterconst teasFrame = 1392.6; // the page's mark, just before the frame that drew the teas console.log(`Event Timing timed the key press at ${enterKey.duration} ms.`);console.log(`The frame with the teas began ${(teasFrame - enterKey.startTime).toFixed(1)} ms after Enter.`);- Line 1 is a comment. Every time is in milliseconds (ms), counted from the start of the page.
- Line 2 holds two numbers from Chrome's Event Timing entry for the Enter key. That's the entry INP is built from, as Event Timing and INP showed.
startTimeis when Asha pressed Enter.durationruns from there to the next frame the browser drew. - Line 3 holds a mark the shop's own code made, just before the browser drew the frame with the teas.
- Line 5 prints
Event Timing timed the key press at 80 ms. - Line 6 subtracts the key press from the teas' frame. It prints
The frame with the teas began 299.5 ms after Enter.
So which is it, 80 ms or 299.5 ms? Both are true. The two stopwatches just stop at different moments. Event Timing stops at the first frame after the key's handlers finish. web.dev puts it exactly: the frame after "all synchronous code initiated from the event handlers". The teas weren't in that frame. The handler had only asked the server for them, and the course's server held its answer for 200 ms. The teas came in a later frame of their own.
Here is the whole search as two bars, both starting at the key press:
Why it matters: web.dev counts an INP of 200 ms or less as good, and 80 ms is well inside that. If the shop only watched INP, this search would look fast. Asha's real wait would never show up in a report.
A café chain gives every branch the same survey card: "Was your table clean? Was the music too loud?" It works in any café. But Leaf and Kettle's owner wants to know something no survey asks: how long from ordering a pot of tea to the first sip. So she starts timing it herself, at her own counter, for every customer.
- In real life: The chain's survey card, the same in every café
- In JavaScript: LCP, CLS and INP: the same metrics on every site
- In real life: "From order to first sip"
- In JavaScript: A custom metric, like
time-to-first-result - In real life: Timing it at her own counter
- In JavaScript: Your own code, marking moments on your own page
- In real life: Every customer, every day
- In JavaScript: Every visitor's browser: the field
A custom metric is that question, asked about your own site. You pick the moment your users start waiting, and the moment the wait is over. The time between them is your metric, and you give it a name. Then you collect it from your real visitors' browsers, which Lab data vs field data called the field.
Choosing what to time
web.dev's article on custom metrics says they measure parts of the experience "that may only apply to your site". Its examples include:
- How long a single-page app takes to move from one "page" to the next.
- How long a page takes to show data it fetched for a logged-in user.
- How long a server-rendered app takes to hydrate: to attach its JavaScript to the HTML the server sent.
To choose yours, ask two questions. What do people come to my site to do? And where do they wait while doing it? For Leaf and Kettle, two waits stand out:
time-to-hero: from the start of the page until the big photo at the top, the hero, is on screen.time-to-first-result: from pressing Enter in the search box until the teas are on screen.
The course built a small copy of the store to time both. A few things on it are slow on purpose:
- The server sends the hero photo 300 ms late.
- When the photo arrives, the page's own code draws the reviews, which keeps it busy for 120 ms.
- The server holds back the search results for 200 ms.
- Just before Asha presses Enter, an analytics script keeps the page busy for 100 ms.
Then the course used Playwright, a tool that drives a real browser, to do what Asha did. It loaded the page, typed the search words and pressed Enter. It did this in Chrome, and in Playwright's builds of Firefox and of WebKit, Safari's engine.
A custom metric is a wait that matters on your site, with a start and an end you choose. Start it when the user acts. End it when the result is on screen. Marks and measures are the stopwatch. Where you press it decides whether the number tells the truth.
The rest of the lesson builds the shop's two metrics, one step at a time:
- The stopwatch: marks and measures, and how the shop collects them from every visitor.
- The sticky note: detail, which says what each number was about.
- The recipe card: where to start and stop, so the number means what you think.
- The timetable: the same metrics, drawn in DevTools.
Marks and measures
You met both in Custom tracks and annotations, as lines in the shop's logbook. A mark is one moment with a name. A measure is a stretch of time between two moments. This section turns them into a stopwatch for the shop's search.
The café's kitchen clock runs all day, but it doesn't know which moments matter. So the owner keeps a stopwatch in her apron. She presses it when an order is taken, and again when the cup lands on the table. Then she writes the reading on a card: "mint tea, 4 minutes".
- In real life: Pressing the stopwatch when the order is taken
- In JavaScript:
performance.mark("search:handler") - In real life: Pressing it again when the cup lands
- In JavaScript:
performance.mark("search:frame") - In real life: The reading she writes on a card
- In JavaScript:
performance.measure(): the time between the two marks - In real life: The kitchen clock behind every reading
- In JavaScript: The page's clock, the one
performance.now()reads
Why not call Date.now() twice and subtract? The User Timing standard starts there. It says the precision of Date.now() "varies between user agents". Marks use the page's own clock instead, the one from The performance timeline. It counts from the start of the page, and it only moves forward. Marks also go into the page's performance timeline, so DevTools and your own code can find them later.
The shop's stopwatch, in code
Here is the shop's search handler, shortened. It presses the stopwatch twice:
search.addEventListener("keydown", (event) => { if (event.key !== "Enter") return; performance.mark("search:handler"); showTeas(search.value).then(() => { requestAnimationFrame(() => { performance.mark("search:frame"); performance.measure("search:from-handler", { start: "search:handler", end: "search:frame" }); }); });});- Line 1 listens for key presses in the search box. Line 2 ignores every key but Enter.
- Line 3 is the first press: a mark named
search:handler, made the moment the handler runs. - Line 4 calls
showTeas(). It stands for the shop's own code, which asks the server for the teas that match and puts them in the list. - Line 5 calls
requestAnimationFrame(). It asks the browser to run a function just before it draws the next frame, as The event loop and frames showed. The teas are in the list by then, so that frame shows them. - Line 6 is the second press: a mark named
search:frame. - Line 7 measures from the first mark to the second, and names the measure
search:from-handler.
The names are yours to choose. The shop follows the pattern in web.dev's example, myTask:start: the feature, a colon, then the moment.
Where a measure starts and ends
measure() takes a name and two points, start and end. You can give each point in three ways:
- A mark's name, like
"search:handler". The measure uses that mark's time. - A number: a time in ms, on the page's clock.
- Nothing at all. A missing
startmeans 0, the start of the page. A missingendmeans now.
The next example tries the first way in your own browser. It puts Chrome's two search marks back on this page with one more option of mark(), startTime. It writes the mark for a moment you choose, instead of now.
// Chrome's two search marks, written back at the times it recorded.performance.mark("search:handler", { startTime: 1170.6 });performance.mark("search:frame", { startTime: 1392.6 }); const search = performance.measure("search:from-handler", { start: "search:handler", end: "search:frame" });console.log(`${search.name} starts at ${search.startTime} ms and lasts ${search.duration.toFixed(1)} ms.`);- Line 1 is a comment.
- Lines 2 and 3 write the two marks at the times Chrome recorded: 1170.6 ms and 1392.6 ms.
- Line 5 measures from the first mark's name to the second's.
- Line 6 prints
search:from-handler starts at 1170.6 ms and lasts 222.0 ms.That's the same measure Chrome made on the shop's page.
Now leave the start out. The shop does this for the hero, because that wait begins with the page itself:
// Chrome's hero:frame mark, written back at the time it recorded.performance.mark("hero:frame", { startTime: 440.9 }); const hero = performance.measure("time-to-hero", { end: "hero:frame" });console.log(`${hero.name} starts at ${hero.startTime} ms and lasts ${hero.duration.toFixed(1)} ms.`);- Line 2 writes Chrome's
hero:framemark, at 440.9 ms. - Line 4 measures up to that mark, with no start.
- Line 5 prints
time-to-hero starts at 0 ms and lasts 440.9 ms.
With no start, the measure begins at 0: the moment the visit started, the page's time origin. The third way, a number, is how the shop starts its search. It comes up in "Designing a good custom metric".
When measure() says no
A stopwatch can't time from a press that never happened. Neither can measure():
// No code on this page made a mark called "search:start".try { performance.measure("time-to-first-result", { start: "search:start" });} catch (error) { console.log(`measure() threw a ${error.name}.`);}- Line 1 says what's true on this page: no code made a mark called
search:start. - Line 3 asks for a measure that starts there.
- Line 5 runs when line 3 throws. It prints
measure() threw a SyntaxError.
The course made the same call on the shop's page, and Chrome, Firefox and WebKit all threw a SyntaxError. In the field, this happens for real. A typo in a mark's name does it. So does a mark that some path through your code never made. Wrap the call in try...catch, or look for the mark first with performance.getEntriesByName().
Two more calls threw a TypeError in all three browsers:
- A measure given
start,endanddurationall at once.durationis one more option: a start and a duration can stand in for an end. Given all three, measure() can't tell which to believe. - A mark with a
startTimebelow 0.
One more rule. Marks can share a name: the shop makes a new search:handler mark on every search. A measure from "search:handler" uses the most recent one, the standard says. So if Asha pressed Enter twice, quickly, the first search's measure would start at her second press. That's one reason the shop's real metric starts from a number it keeps, not from a name. And old marks still pile up, so clear them once you've read them, as Custom tracks and annotations showed.
Reading your measures in the field
So far, the measures sit in Asha's browser. The shop needs them from every visitor's browser, in the field. The performance timeline lesson showed how to hear about new entries: an observer, the colleague who taps you on the shoulder when a line is written. web.dev suggests the same for User Timing: observe the entries whose type is measure.
const observer = new PerformanceObserver((list) => { for (const measure of list.getEntries()) { sendToAnalytics(measure.name, measure.duration, measure.detail); }});observer.observe({ type: "measure", buffered: true });- Lines 1 to 5 make the observer. Line 2 goes through each new measure it's told about.
- Line 3 calls
sendToAnalytics(). It stands for your own code that sends the name, the time and the detail to your server. Real user monitoring, the next module in Measuring real users, builds it. - Line 6 watches measures.
buffered: truealso brings the ones made before the observer started.
web.dev gives one more reason to use measures rather than numbers in your own variables: tools know where to find them. Chrome's DevTools draws them, and web.dev says "many analytics providers will also automatically track any measurements you make".
Attaching detail
Say the shop's analytics fills up with search times. Most are quick, and a few are slow. Which searches were the slow ones? A list of numbers can't say. Was it a search with three results, or one with hundreds?
You need a note on each number. That's what detail is for: any data you attach to a mark or a measure.
At the café, the owner writes each order's wait on a timesheet: "4 minutes", "11 minutes". On each line she sticks a small note: "table 4, two pots of mint tea". At the end of the week, she reads the notes beside the slow times. Every slow order was for a big table.
- In real life: A line on the timesheet: "11 minutes"
- In JavaScript: A measure and its duration
- In real life: The sticky note: "table 4, two pots of mint tea"
- In JavaScript:
detail, like{ query: "mint", count: 3 } - In real life: Reading the notes beside the slow times
- In JavaScript: Grouping your numbers by what's in
detail
The shop's time-to-first-result measure carries the search words and how many teas came back. In Chrome, its detail was { query: "mint", count: 3 }. Now the shop can group its search times by query, and by how many results each search had.
The note is a copy
When you pass a detail to mark() or measure(), the browser doesn't keep your object. It keeps a copy, made right then. The standard has it copied with the structured clone algorithm, the same one structuredClone() uses. It's like a photocopy of your note: change the original afterwards, and the copy stays as it was.
const note = { query: "mint" };const mark = performance.mark("search:handler", { detail: note }); note.query = "lemon";console.log(`The mark's detail still says ${mark.detail.query}.`);console.log(`Same object? ${mark.detail === note}`);- Line 1 makes the note: the search words.
- Line 2 makes a mark with that note as its detail, and keeps the entry as
mark. - Line 4 changes the note afterwards.
- Line 5 prints
The mark's detail still says mint. - Line 6 prints
Same object? false. The entry holds a copy, not your object.
The course did the same on the shop's page in Chrome, Firefox and WebKit. All three kept "mint". Why it matters: you can reuse or change your own objects after you mark, and the entry won't change under you.
Only data that can be copied
Some things can't be copied. MDN's page on the algorithm names two you might reach for: functions, and elements from the page. Attach one, and the mark is never made:
try { performance.mark("search:handler", { detail: { query: "mint", onDone() {} } });} catch (error) { console.log(`mark() threw a ${error.name}.`);}- Line 2 makes a mark whose detail holds a function,
onDone. - Line 4 prints
mark() threw a DataCloneError.ADataCloneErrormeans "this can't be copied".
All three browsers threw it. So put plain data in detail: strings, numbers, true and false, arrays, and plain objects of those. That's what you'd send to analytics anyway. To say which element was slow, put in its id or a short name, not the element itself.
Designing a good custom metric
The stopwatch is exact, to a fraction of a millisecond. But it only times what lies between your two presses. Press at the wrong moments, and you get a precise number for the wrong wait.
Leaf and Kettle's recipe card for masala chai says: "Start timing when the milk goes in. Ready when it rises to the rim." Every cook times it the same way. So when one shift says 6 minutes and another says 9, the owner knows the second shift really was slower. If each cook started the clock at a different step, the numbers couldn't be compared.
- In real life: The recipe card
- In JavaScript: Your metric, written down before you code it
- In real life: "Start timing when the milk goes in"
- In JavaScript: The start: the moment the user acts
- In real life: "Ready when it rises to the rim"
- In JavaScript: The end: the moment the result is on screen
- In real life: Every cook timing it the same way
- In JavaScript: Every visit, in every browser, measured the same way
So write your metric down before you code it. The shop's card for its search reads:
- Name:
time-to-first-result. - Start: when Asha presses Enter.
- End: when the teas are on screen.
- Note: the search words, and how many teas came back.
The next two parts take the start and the end in turn.
Start when the user acts
The shop's first stopwatch started in the handler. That feels like "when Asha pressed Enter", but it isn't. The User Timing standard's own example makes the point. In its words: "We can't mark the time the click handler executes since that will miss latency to process the event, so instead we use the event hardware timestamp."
A handler runs when the main thread gets to it. If other code is running, the key press waits, like a customer who rings the bell while the cook finishes someone else's order. Event Timing and INP called that wait the input delay.
The moment of the press itself is in the event's timeStamp. MDN says it counts from the page's time origin, the same 0 that performance.now() counts from. So it can be a measure's start, as a number:
performance.measure("time-to-first-result", { start: event.timeStamp, end: "search:frame",});- Line 1 makes a measure named
time-to-first-result. The shop calls it in the same frame callback as before. - Line 2 gives the start as a number:
event.timeStamp, the moment of the key press. - Line 3 gives the end as a mark's name,
search:frame.
How much does the start matter? Here is Chrome's search, timed both ways:
// Chrome's search for "mint". Times in ms from the start of the page.const enterPressed = 1093.1; // the Enter key's event.timeStampconst handlerRan = 1170.6; // the search:handler markconst teasFrame = 1392.6; // the search:frame mark console.log(`From Enter: ${(teasFrame - enterPressed).toFixed(1)} ms`);console.log(`From the handler: ${(teasFrame - handlerRan).toFixed(1)} ms`);- Lines 2 to 4 hold three recorded times: the key press, the handler's mark, and the frame mark.
- Line 6 prints
From Enter: 299.5 ms. - Line 7 prints
From the handler: 222.0 ms.
The handler started 77.5 ms after Asha pressed Enter, because the analytics script was still running. A metric that starts in the handler leaves those 77.5 ms out. Asha waited through them all the same.
Chrome's own Event Timing entry agrees. The key's event.timeStamp was exactly the entry's startTime, 1093.1 ms. The handler's mark was exactly its processingStart, when the handler began. So those 77.5 ms are the input delay Event Timing reports. Why it matters: start at the event's timeStamp, and your metric counts the wait the user felt.
One browser in the recording didn't fit this picture. In Firefox, the key's timeStamp and the handler's mark were the same, 1265 ms. So Firefox's search came out at 212 ms both ways. The recording can't tell why. Maybe Playwright's key press only reached Firefox after the busy script, or maybe Firefox stamped it late. Either way, the same metric can come out shorter in one browser than in another. Look at each browser's numbers on their own.
End when the result is on screen
Now the end. The hero has no key press: its wait starts with the page. Where should it stop? The shop tried two marks:
hero:load, made in the photo'sloadevent, when its file had arrived.hero:frame, made in the nextrequestAnimationFrame()callback, just before the next frame.
The photo also had an elementtiming attribute. That's Element Timing, from Paint, LCP, and layout shift APIs: the browser writes an entry when the element is drawn. Chrome supports it, so Chrome could say when the photo was really on screen:
// Chrome, the hero photo. Times in ms from the start of the page.const loadMark = 318.9; // made in the photo's load eventconst frameMark = 440.9; // made in the next requestAnimationFrame callbackconst onScreen = 332; // Element Timing: when the photo was drawn console.log(`The load mark was ${(onScreen - loadMark).toFixed(1)} ms early.`);console.log(`The frame mark was ${(frameMark - onScreen).toFixed(1)} ms late.`);- Lines 2 and 3 hold the two marks Chrome recorded, at 318.9 ms and 440.9 ms.
- Line 4 holds Element Timing's
renderTime: 332 ms, when the photo was drawn. - Line 6 prints
The load mark was 13.1 ms early. - Line 7 prints
The frame mark was 108.9 ms late.
The load mark was early because a file that has arrived still has to be drawn. The frame mark was late for another reason. Right after it asked for the frame callback, the load handler drew the reviews for 120 ms. The callback had to wait for that work. Meanwhile, Chrome had put the photo on screen at 332 ms.
Why it matters: ended at the load mark, Chrome's time-to-hero would look 13.1 ms better than what Asha saw. Ended at the frame mark, it would look 108.9 ms worse.
For an element that matters to you, web.dev points to Element Timing: "The LCP API is actually built on top of the Element Timing API". You add the elementtiming attribute, then observe element entries. Only Chrome supports it. Firefox and WebKit don't, so the course used their LCP entries instead, since the photo was the largest thing on the page.
In those browsers, the frame mark is the fallback: the best your own code can do. Each one's load mark came early, by 120 ms in Firefox and 144 ms in WebKit. Their frame marks landed in different places.
In the playground, each tick on the marks row is one mark, and each bar on the measures row is one measure. The bottom row shows when the photo was on screen, and the teas' frame mark. Pick each browser to see where its frame mark landed:
hero:load318.9 mshero:frame440.9 mssearch:handler1,170.6 mssearch:frame1,392.6 mstime-to-hero440.9 mstime-to-first-result299.5 mssearch:from-handler222 ms
Element Timing saw the photo on screen at 332 ms. That's 108.9 ms before the frame mark, and 13.1 ms after the load mark. The search took 299.5 ms from Enter, and 222 ms from the handler.
One more limit of frame marks. MDN notes that requestAnimationFrame() calls are paused in most browsers while the tab is in the background. So if Asha switched tabs while the teas loaded, the frame mark could wait for her to come back. The metric would then count her time away, not the shop's. The web-vitals library, in the next lesson, handles hidden tabs for its own metrics.
Keep the stopwatch light
One last rule, and web.dev puts it first: "The first rule of effective performance measurement is to make sure your performance measurement techniques aren't causing performance issues themselves."
Its example of what not to do is a requestAnimationFrame() loop that runs every frame to spot slow tasks. Such hacks "actually affect performance themselves", web.dev says, by draining the battery for one. A few marks and one measure per search are tiny next to that. Custom tracks and annotations timed calls like these, and each one took far less than a millisecond.
Showing your metrics in DevTools
The shop's metrics now go to analytics. They also show up in Chrome's Performance panel, next to everything else the browser did. Custom tracks and annotations covered the tools, like adding your own row to the shop's timetable. In short:
- A plain mark or measure goes on the Timings track.
- A
devtoolsobject inside detail moves a measure to a track of your own. It turns a mark into a marker, which Chrome's docs describe as spanning "across all tracks". console.timeStamp()draws an entry on your track, and does nothing else.
The shop used all three, and the course recorded a trace in Chrome while Playwright searched. Read each row across: what the shop wrote, what it carried, and where the Performance panel draws it.
| What the shop wrote | What it carried | Where DevTools draws it |
|---|---|---|
search:handler | A mark with no detail. | On the Timings track, as one moment. |
hero:frame | A mark whose detail.devtools says dataType: "marker". | As a line down across every track. |
time-to-first-result | A measure whose detail.devtools names the track Search in the group Shop. | On the Shop > Search track, as a bar. |
draw results | A console.timeStamp() call, with the track Search and the group Shop. | On the same Shop > Search track. It leaves no entry for your code to read. |
The time-to-first-result row is worth a second look. Its detail held two notes in one: the query and the count for the shop's analytics, and a devtools object for DevTools. The devtools object repeated the query and the count as properties, so DevTools shows them when you select the bar. One sticky note serves both readers.
The last row is the odd one out. On the shop's page, performance.getEntriesByName("draw results") found nothing, in all three browsers. Chrome's docs say console.timeStamp() "does not add entries to the browser's internal performance timeline". So it shows up in DevTools, and never reaches your analytics.
To see your own tracks, open the Performance panel's Capture settings and tick Show custom tracks. Select an entry, and the Summary tab shows its detail.
Good metric or misleading?
Each card below is a choice the shop could make. Ask the recipe card's two questions. Does it start when the user acted? Does it end when they saw the result? Then sort it.
- Start the search metric at the Enter key's
event.timeStamp - End when the teas are on screen, not when the data arrives
- Use Element Timing for the hero where the browser supports it
- Attach the search words and the result count as
detail - End the hero metric at the photo's
loadevent - Start the search metric when the handler runs
- Use the Enter key's Event Timing duration, the number INP uses, as the search time
- Time it with
Date.now() - Run a
requestAnimationFrame()loop to spot slow tasks
Sort each choice by whether the number it gives tells the truth about the wait.
Common mix-ups
- "INP already times my search." No. Event Timing stopped at the next frame, 80 ms after Enter. The teas came in a frame 299.5 ms after it.
- "The load event means the photo is on screen." No. It means the file arrived. The load mark was early in every browser: by 13.1 ms in Chrome, 120 ms in Firefox and 144 ms in WebKit.
- "The handler runs when the user presses the key." No. It runs when the main thread is free. In Chrome, the handler started 77.5 ms after Enter.
- "detail stays linked to my object." No. The entry holds a copy, made when you call mark() or measure().
- "A missing mark means the measure starts at 0." No. Only a missing start means 0. A start that names a mark nobody made throws a SyntaxError.
- "console.timeStamp() makes an entry my code can read." No. It only draws in DevTools.
Practice exercises
Answer first, then use the hints.
The shop left the start out of its search measure. What does line 6 print?
// Chrome's two search marks, written back at the times it recorded.
performance.mark("search:handler", { startTime: 1170.6 });
performance.mark("search:frame", { startTime: 1392.6 });
const search = performance.measure("search:from-handler", { end: "search:frame" });
console.log(`${search.name} starts at ${search.startTime} ms and lasts ${search.duration.toFixed(1)} ms.`);It prints search:from-handler starts at 0 ms and lasts 1392.6 ms. With no start, the measure counts from the start of the page, so it holds the whole visit up to the teas.
Say the analytics script hadn't run, and Chrome's handler started 10 ms after Enter. What does the second line print?
// Chrome's search for "mint". Times in ms from the start of the page.
const enterPressed = 1093.1; // the Enter key's event.timeStamp
const handlerRan = 1103.1; // the search:handler mark
const teasFrame = 1392.6; // the search:frame mark
console.log(`From Enter: ${(teasFrame - enterPressed).toFixed(1)} ms`);
console.log(`From the handler: ${(teasFrame - handlerRan).toFixed(1)} ms`);It prints From the handler: 289.5 ms. With almost no input delay, the two starts nearly agree. The time from Enter stays 299.5 ms, because Asha's wait didn't change.
In which browser did the hero's frame mark land exactly when the photo was on screen?
Firefox. Its frame mark and its LCP entry for the photo were both at 541 ms. In the other two browsers, the frame mark missed.
How long was WebKit's time-to-hero measure, in ms?
461 ms. It ran from 0 to WebKit's frame mark, because the shop's time-to-hero has no start.
This search metric leaves out part of Asha's wait. What should line 3 use instead of performance.now()?
search.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
const startedAt = performance.now();
showTeas(search.value).then(() => {
requestAnimationFrame(() => {
performance.measure("time-to-first-result", { start: startedAt });
});
});
});search.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
const startedAt = event.timeStamp;
showTeas(search.value).then(() => {
requestAnimationFrame(() => {
performance.measure("time-to-first-result", { start: startedAt });
});
});
});Use event.timeStamp. performance.now() on line 3 reads the clock when the handler runs, so the metric leaves out the input delay. event.timeStamp is the moment of the key press, on the same clock.
Open DevTools on this page. In the Console, run performance.mark("navigationStart"). What is the name of the error it throws?
SyntaxError. The standard keeps the names of the old performance.timing fields for itself. Chrome, Firefox and WebKit all threw it on the shop's page.
Check your understanding
For each question, ask where the stopwatch starts, where it stops, and what the entry keeps.
Question 1 of 7What does the second line of the From Enter example print?
Read the code, then predict// Chrome's search for "mint". Times in ms from the start of the page. const enterPressed = 1093.1; // the Enter key's event.timeStamp const handlerRan = 1170.6; // the search:handler mark const teasFrame = 1392.6; // the search:frame mark console.log(`From Enter: ${(teasFrame - enterPressed).toFixed(1)} ms`); console.log(`From the handler: ${(teasFrame - handlerRan).toFixed(1)} ms`);Choose an answer to see the explanation.
Question 2 of 7What does the first line of the hero example print?
Read the code, then predict// Chrome, the hero photo. Times in ms from the start of the page. const loadMark = 318.9; // made in the photo's load event const frameMark = 440.9; // made in the next requestAnimationFrame callback const onScreen = 332; // Element Timing: when the photo was drawn console.log(`The load mark was ${(onScreen - loadMark).toFixed(1)} ms early.`); console.log(`The frame mark was ${(frameMark - onScreen).toFixed(1)} ms late.`);Choose an answer to see the explanation.
Question 3 of 7Why didn't Event Timing's 80 ms for the Enter key include the teas?
Choose an answer to see the explanation.
Question 4 of 7Why start a search metric at the key's event.timeStamp, not in the handler?
Choose an answer to see the explanation.
Question 5 of 7You call mark() with a detail object, then change the object. What does the entry's detail show?
Choose an answer to see the explanation.
Question 6 of 7What does performance.measure("x", { start: "never-marked" }) do when no such mark exists?
Choose an answer to see the explanation.
Question 7 of 7How is console.timeStamp() different from a measure with detail.devtools?
Choose an answer to see the explanation.
Key takeaways
- A custom metric is a wait that matters on your site: a name, a start and an end you choose.
- A mark is a named moment. A measure is the time between two points: a mark's name, a number, or nothing.
- A missing start means 0, and a missing end means now. A mark nobody made throws a SyntaxError.
- A buffered observer of measure entries collects your metrics from every visitor.
- detail is a copy of plain data, made when you call mark() or measure().
- Start at the event's timeStamp, not in the handler.
- End when the result is on screen: Element Timing where you can, a frame mark as the fallback.
- DevTools draws marks and measures on the Timings track, and on your own tracks with detail.devtools.
Remember the one-liner.
Start the stopwatch when the user acts, and stop it when they see the result.
Sources
The facts on this page come from these standards and docs:
- User Timing, W3C.
- Custom metrics, web.dev.
- Interaction to Next Paint (INP), web.dev.
- Window: requestAnimationFrame() method, MDN.
- The structured clone algorithm, MDN.
- Event: timeStamp property, MDN.
- Customize your performance data with extensibility API, Chrome for Developers.
- Performance features reference, Chrome for Developers.
Coming next: The web-vitals library, also in Measuring real users. It measures LCP, INP and CLS the way Chrome does, and points at what caused each number.