Navigation Timing and Resource Timing
Timestamps for every phase of every request, and what cross-origin rules hide. A small site on two origins, visited three times in Chrome, Firefox and WebKit, with every entry and every request its servers answered.
- 01Read the entries from codeGet the page's entry and every file's entry, and turn their times into waits and downloads.
- 02Explain the zeros from other sitesSay why a file from another origin reads 0, and how Timing-Allow-Origin shares its times.
- 03Tell where a file came fromUse the sizes and deliveryType to tell the network, the cache and a revalidation apart, in each browser.
- 04Read the server's own noteUse Server-Timing to see which step inside the server made the browser wait.
Timestamps for every request
Asha opens the Leaf and Kettle page on her phone. She sees the heading Mint tea and two photos. To her, it's one page. To her browser, it was 7 downloads: the HTML, a stylesheet, two scripts, two photos, and a list of teas that one of the scripts asked for.
In The navigation timeline, you read the browser's stopwatch for the first of those downloads: the page itself. But when a page feels slow, the reason can be one of the other files. Was it a photo? Did the stylesheet come from the cache, or all the way from the server? How long did the server spend thinking? You can't sit next to Asha with DevTools open. You need the answers from your own code, on every real visit.
The browser has them. For every download, it writes an entry in the page's performance timeline, the logbook from The performance timeline. This lesson reads those entries, one question at a time.
To see what real browsers write, the course built a small copy of the Leaf and Kettle page. Its HTML, stylesheet, scripts and list of teas came from one server. Its two photos came from a second server, the way a site can keep its images on a separate image server. Then the course opened the page three times in each of Chrome, Firefox and WebKit, Safari's engine:
- a first visit, with nothing saved yet;
- a second visit to the same page;
- a reload.
After each visit, it saved every entry the page could read. The two servers also kept a log of every request they answered, so the course knows what really happened. Here are two of the entries from Chrome's first visit:
1// Two entries from Chrome's first visit, in ms from the start of the visit.2const page = { entryType: "navigation", responseEnd: 98.3 };3const photo = { entryType: "resource", startTime: 109, responseEnd: 116.2 };4 5console.log(`The last byte of the HTML came at ${page.responseEnd} ms.`);6console.log(`The browser started fetching the photo at ${photo.startTime} ms.`);7console.log(`The photo's last byte came at ${photo.responseEnd} ms.`);- Line 1 is a comment. Every time is in milliseconds (ms) from the moment the visit started, the same clock as in The navigation timeline.
- Line 2 is part of the page's own entry. Its type is
navigation. - Line 3 is part of the entry for one of the photos. Its type is
resource. Every file the page fetches gets one of these. - Lines 5 to 7 print
The last byte of the HTML came at 98.3 ms.,The browser started fetching the photo at 109 ms.andThe photo's last byte came at 116.2 ms.
Both entries use one clock, so you can put them side by side. The photo started after the HTML had arrived: the browser only learns a file's address when it reads the tag that names it. Here is the whole first visit, one bar per entry:
Most of the visit went on the HTML. The six files came after it, close together. Each bar is one entry, and each entry is like a note that came with a parcel.
You order a teapot, a box of mint tea and a strainer. They arrive as three parcels, and each has a delivery note inside. The note says when the parcel was ordered, when it was sent out, and when it reached your door. It also says how heavy it was. With the three notes on the table, you can see which parcel kept you waiting.
- In real life: One parcel
- In JavaScript: One file the page fetched
- In real life: The delivery note inside it
- In JavaScript: That file's resource entry
- In real life: Ordered, sent out, at your door, each with a time
- In JavaScript:
startTime,requestStart,responseStartandresponseEnd - In real life: The weight written on the note
- In JavaScript:
transferSize: the bytes that crossed the network
The page's own entry is the note for the first parcel, the HTML. The navigation timeline compared it to a tracking page. Now every other parcel has a note too.
The browser writes one entry for the page and one entry for every file it fetches. Each entry holds the file's times and sizes, and your code can read them on every visit. Some fields read 0 on purpose. Knowing when a 0 means "hidden" and when it means "from the cache" is most of the skill.
The rest of the lesson takes one question at a time:
- How does my code get the page's own entry?
- What does each file's entry hold?
- Why does a photo from another server read 0?
- Did a file come from the cache?
- Did a file hold up the first paint?
- What was the server doing while the browser waited?
Navigation Timing fields
Start with the first parcel, the HTML. The navigation timeline walked through every time in its entry, from leaving the old page to the load event. This section is about getting that entry from your own code, and about a few fields that lesson didn't use.
You ask the performance timeline for it by its type:
1const [page] = performance.getEntriesByType("navigation");2 3console.log(page.name);4console.log(page.type);5console.log(page.responseStart - page.requestStart);- Line 1 asks for every entry whose type is
navigation. A page has only one, but the method always gives back a list. The square brackets aroundpagetake the first item out of that list. - Line 3 prints
name: the page's address. - Line 4 prints
type: how the visit started. - Line 5 prints how long the browser waited for the server: the subtraction from The navigation timeline.
This code needs a real page, so it has no Run button here. Paste it into the DevTools console on any page to try it.
The type field is new. The Navigation Timing standard gives it three values:
navigate: a normal visit, such as following a link or typing the address.reload: the visitor reloaded the page.back_forward: the visitor came back with the Back or Forward button.
Chrome reported navigate, navigate and reload for the course's three visits. The second visit opened the page's address again, so it counted as a normal visit. Only the real reload said reload. Keep the type next to every number you collect. A first visit and a reload aren't the same kind of visit: on a reload, many files can come from the cache.
Now the times. Here is the first visit's request and response:
1// Chrome's navigation entry on the first visit, in ms.2const page = { requestStart: 4.1, responseStart: 97.5, responseEnd: 98.3 };3 4const wait = page.responseStart - page.requestStart;5const download = page.responseEnd - page.responseStart;6console.log(`Waiting for the server: ${wait.toFixed(1)} ms`);7console.log(`Downloading the HTML: ${download.toFixed(1)} ms`);- Line 2 holds three times from Chrome's entry.
- Line 4 subtracts the moment the request left from the first byte: the wait for the server.
- Line 5 subtracts the first byte from the last: the download.
- Lines 6 and 7 print
Waiting for the server: 93.4 msandDownloading the HTML: 0.8 ms.
The HTML is only 498 bytes, so the download was almost instant. Nearly all the time went on waiting for the server. What was the server doing all that time? The browser can't see inside it. The last section of this lesson lets the server answer for itself.
The page's entry has one more surprise. The standard builds it on top of a file's entry: a PerformanceNavigationTiming is a PerformanceResourceTiming with extra fields. So the page's entry has a file's fields too:
1// Chrome's navigation entry again. It has a file's fields too.2const page = { initiatorType: "navigation", encodedBodySize: 498, transferSize: 798 };3 4console.log(`Who asked for it: ${page.initiatorType}`);5console.log(`The HTML's own bytes: ${page.encodedBodySize}`);6console.log(`Bytes that crossed the network: ${page.transferSize}`);- Line 2 holds three of those fields, recorded in Chrome.
- Line 4 prints
Who asked for it: navigation. For a file, this field names the tag or the call that asked for it. For the page, it's alwaysnavigation. - Lines 5 and 6 print
The HTML's own bytes: 498andBytes that crossed the network: 798.
Everything the next sections say about a file's entry works on the page's entry too. That includes the gap of 300 between those two sizes, which the sizes section explains.
Resource Timing fields
Now the other parcels. Each file the page fetches gets a resource entry: a PerformanceResourceTiming object, from the Resource Timing standard. One call gives you all of them:
1const files = performance.getEntriesByType("resource");2 3for (const file of files) {4 console.log(file.name, file.initiatorType, file.duration);5}- Line 1 asks for every entry whose type is
resource. You get one per file, in the order the browser started them. - Lines 3 to 5 loop over them. Line 4 prints three fields of each:
name,initiatorTypeandduration.
Each field answers one question about the file:
name: which file? It's the file's full address.initiatorType: who asked for it?startTime: when did the browser start on it?duration: how long did it take? It runs fromstartTimeto the last byte,responseEnd.
In between are the same times the page's entry has, like requestStart and responseStart. You subtract them the same way. So who asked for each file on Chrome's first visit?
1// Who asked for each file on Chrome's first visit: its initiatorType.2const files = {3 "styles.css": "link",4 "blocking.js": "script",5 "photo-tao.svg": "img",6 "data.json": "fetch",7};8 9for (const [name, initiator] of Object.entries(files)) console.log(`${name}: ${initiator}`);- Lines 3 to 6 hold four files and their recorded
initiatorType. - Line 9 prints one line per file:
styles.css: link,blocking.js: script,photo-tao.svg: imganddata.json: fetch.
Three of those name a tag in the HTML: a <link>, a <script> and an <img>. The fourth is different. Nothing in the HTML names data.json. A script asked for it with fetch(), and the browser wrote an entry for that too. So the calls your own scripts make are in the list. You can time your API calls on real visits without writing any timing code.
Next: when did each file start, and how long did it take?
1// When three files started, and how long each took, on Chrome's first visit.2const files = [3 { name: "styles.css", startTime: 108.7, responseEnd: 114.7 },4 { name: "deferred.js", startTime: 109, responseEnd: 118.7 },5 { name: "data.json", startTime: 126.1, responseEnd: 127.2 },6];7 8for (const { name, startTime, responseEnd } of files) {9 console.log(`${name}: started at ${startTime} ms, took ${(responseEnd - startTime).toFixed(1)} ms`);10}- Lines 3 to 5 hold three files' recorded
startTimeandresponseEnd. - Line 9 prints each start, and each duration:
responseEndminusstartTime. - It prints
styles.css: started at 108.7 ms, took 6.0 ms,deferred.js: started at 109 ms, took 9.7 msanddata.json: started at 126.1 ms, took 1.1 ms.
data.json was the quickest of the three, yet it finished last. It started about 17 ms after the others. Why? It was the deferred script that asked for it. The browser runs a deferred script only once it has finished reading the HTML, at 125.4 ms here. Only then could the script ask for data.json.
So a file can be late without being slow. A late startTime means the file waited to be asked for. duration shows the trip itself. Reading waterfalls calls this a request chain.
One more field: nextHopProtocol names the protocol the file came over. On this visit, every file but one said http/1.1, because the course's small server spoke HTTP/1.1. On a real site it can say h2 or h3. The odd one out is the next section's subject.
Timing-Allow-Origin
The two photos came from the second server, so they're files from another origin. An origin is the scheme, host and port of an address together, like https://completefrontend.com. The page came from one origin, and the photos from another.
The course sent the two photos in almost the same way: same server, same kind of file, same bytes. There was one difference. One came with a header called Timing-Allow-Origin, and the other didn't. Here is the one without it:
1// Chrome's entry for the photo sent without Timing-Allow-Origin, first visit.2const photo = { startTime: 109.3, requestStart: 0, responseStart: 0, responseEnd: 116.2, transferSize: 0 };3 4console.log(`From start to end: ${(photo.responseEnd - photo.startTime).toFixed(1)} ms`);5console.log(`Waiting for the server: ${photo.responseStart - photo.requestStart} ms`);6console.log(`Bytes over the network: ${photo.transferSize}`);- Line 2 holds Chrome's recorded entry for that photo.
- Line 4 prints
From start to end: 6.9 ms. The start and the end are there. - Line 5 prints
Waiting for the server: 0 ms, because both times were 0. - Line 6 prints
Bytes over the network: 0.
The photo did take time to arrive, and it did weigh something. Its entry just doesn't say. The browser hid the details.
Most of your parcels come from one shop, and their notes show every time. One parcel comes from another company. Its note has most of the times blacked out. Only "sent" and "delivered" are left. That company fills in the rest only for the customers it has agreed to share them with.
- In real life: A parcel from another company
- In JavaScript: A file from another origin
- In real life: Most of the times blacked out
- In JavaScript:
requestStart,responseStartand the sizes read 0 - In real life: "Sent" and "delivered" still showing
- In JavaScript:
startTimeandresponseEnd - In real life: The company agreeing to share with you
- In JavaScript: A
Timing-Allow-Originheader that names your site
Why hide them? The Resource Timing standard explains in its privacy section. Times can reveal what a visitor did on other sites. If a file from another site arrives very fast, it was probably in the cache, so the visitor has probably been to that site before. The standard calls this "statistical fingerprinting".
So for a file from another origin, the browser keeps only the start and the end. It sets the rest to 0 or an empty string:
- the connection times;
requestStartandresponseStart;nextHopProtocol, the odd one out from the last section;- the sizes.
Here is the other photo, the one sent with the header:
1// Chrome's entry for the photo sent with Timing-Allow-Origin, first visit.2const photo = { startTime: 109, requestStart: 114.7, responseStart: 115.7, responseEnd: 116.2, transferSize: 457 };3 4console.log(`From start to end: ${(photo.responseEnd - photo.startTime).toFixed(1)} ms`);5console.log(`Waiting for the server: ${(photo.responseStart - photo.requestStart).toFixed(1)} ms`);6console.log(`Bytes over the network: ${photo.transferSize}`);- Line 2 holds Chrome's recorded entry for the second photo.
- It prints
From start to end: 7.2 ms,Waiting for the server: 1.0 msandBytes over the network: 457.
The header made the difference. The photo's server sends it with the photo, naming the sites that may see the times. On a real site it could look like this:
Timing-Allow-Origin: https://leafandkettle.exampleLine 1 names one origin: the site that may read the full entry. The course's photo server named the course's site in the same way. If your images, fonts or scripts come from a CDN, a separate network of servers that delivers files for you, set it up to send this header for your site. Otherwise those files' details will read 0 in your data.
One thing the header doesn't always share is the sizes. For the allowed photo on the first visit, Chrome gave all three sizes. Firefox gave a transferSize of 300 but body sizes of 0. WebKit gave 0 for all three, though it showed the times. MDN says the header doesn't share "properties that describe the response itself, such as its body size". The standard also lets a browser keep the sizes at 0 even with the header. So treat another origin's sizes as unknown unless your data shows them.
Transfer size and detecting cache hits
Back to Asha. The second time she opens the page, it loads faster. Her browser kept some files from the first visit in its HTTP cache, the store of files it saved earlier. Your code can tell which files came from there. The clue is in the sizes.
Every entry has three sizes, all in bytes:
encodedBodySize: the file's body as it was sent. If the server compressed the file, this is the compressed size.decodedBodySize: the body after the browser unpacked it.transferSize: what crossed the network for this file. That's the body, plus a fixed amount for the headers.
1// Chrome's three sizes for styles.css on the first visit, in bytes.2const styles = { encodedBodySize: 144, decodedBodySize: 3780, transferSize: 444 };3 4console.log(`As sent, compressed: ${styles.encodedBodySize}`);5console.log(`After unpacking: ${styles.decodedBodySize}`);6console.log(`Over the network: ${styles.transferSize}`);- Line 2 holds Chrome's three sizes for styles.css on the first visit.
- Lines 4 to 6 print
As sent, compressed: 144,After unpacking: 3780andOver the network: 444.
The server compressed the stylesheet with gzip, so 144 bytes travelled and 3,780 came out. The stylesheet repeats one line many times, which compresses very well. Text compression covers how. The 444 is the 144 bytes of body plus 300. Hold on to that 300.
Now the second visit. Some of the files never travelled at all:
You need a box of mint tea. Before you order one, you check the shelf, and there's a box from last week. You use it. Its delivery note would say it weighed nothing on the way, because nothing travelled. The tea is all still there. Another time you're not sure the box is fresh, so you phone the shop to ask. They say it's fine. Only the phone call travelled.
- In real life: The box already on your shelf
- In JavaScript: A file in the browser's HTTP cache
- In real life: It weighed nothing on the way
- In JavaScript:
transferSizeis 0 - In real life: The tea is still inside
- In JavaScript:
encodedBodySizeanddecodedBodySizekeep their values - In real life: A quick call: is my box still fresh?
- In JavaScript: A revalidation:
transferSizeis 300
The Resource Timing standard spells out the rule for transferSize. Here it is as a small function:
1// The Resource Timing standard's rule for transferSize, as a function.2function transferSize(cacheMode, encodedBodySize) {3 if (cacheMode === "local") return 0;4 if (cacheMode === "validated") return 300;5 return encodedBodySize + 300;6}7 8console.log(transferSize("", 144));9console.log(transferSize("local", 144));10console.log(transferSize("validated", 34));- Line 2 takes the entry's cache mode, which says how the cache was involved, and its compressed body size.
- Line 3: a cache mode of
"local"means the file came straight from the browser's cache. That gives 0. - Line 4:
"validated"means the browser asked the server whether its copy was still good, and the server said yes. That gives 300. - Line 5: any other file came over the network. That gives the body plus 300.
- Lines 8 to 10 try each case and print
444,0and300.
That "is my copy still good?" question is called a revalidation. If the file hasn't changed, the server answers with the status 304 Not Modified, and no body. Only headers travel.
Why a fixed 300, and not the headers' real size? The standard says the constant "replaces exposing the total byte size of the HTTP headers, as that might expose the presence of certain cookies." Cookies travel in headers, so the headers' real size could give them away.
Now turn the rule around. Given the three sizes, how did a file arrive? Here are three files from Chrome's second visit:
1// Chrome's sizes on the second visit: [transferSize, encodedBodySize, decodedBodySize].2const files = {3 "styles.css": [0, 144, 3780],4 "data.json": [300, 34, 34],5 "photo-no-tao.svg": [0, 0, 0],6};7 8function delivery([transfer, encoded, decoded]) {9 if (transfer === 0 && decoded === 0) return "hidden";10 if (transfer === 0) return "cache";11 if (transfer === 300) return "revalidated";12 return "network";13}14for (const [name, sizes] of Object.entries(files)) console.log(`${name}: ${delivery(sizes)}`);- Lines 3 to 5 hold three files' recorded sizes, in the order
transferSize,encodedBodySize,decodedBodySize. - Lines 8 to 13 are the function
delivery. Line 9 says a file with no bytes at all, not even a body, is hidden. - Line 10 says no transfer, but a body, means the cache. Line 11 says exactly 300 means a revalidation.
- Line 12 says anything else came over the network.
- Line 14 prints
styles.css: cache,data.json: revalidatedandphoto-no-tao.svg: hidden.
Line 9 has to come first. The hidden photo has a transferSize of 0 too, like a cache hit. Only its body sizes tell the two apart: a cache hit still knows how big its file is.
That's how Chrome reports the cache, and its entries matched the servers' log. The other two engines reported the same visit in their own ways. Read this table across: each row is one thing that happened on the second visit, and what each browser's entry said about it.
| On the second visit | Chrome's entry | Firefox's entry | WebKit's entry |
|---|---|---|---|
| styles.css came from the cache. | 0, 144 and 3,780 bytes. | 0, 144 and 3,780 bytes. | 0, 0 and 0 bytes. |
| data.json went back to the server. | 300 bytes. The server sent 304. | 334 bytes. The server sent 304. | 334 bytes. |
| deliveryType for styles.css. | It said "cache". | The field wasn't there. | It was an empty string. |
Chrome follows the standard's rule. The server answered Firefox's question about data.json with a 304 too, but Firefox's entry said 334 bytes, as if the whole file had travelled. WebKit gave a cache hit the same three zeros as a hidden file. Firefox also left some files out of its list entirely on later visits.
So before you trust a size, check which browser sent it. Was WebKit's 334 a real download, or a 304 like Firefox's? Pick an engine and a visit. The line under the controls lists every request the two servers answered. The table's last three columns come in the next two sections.
| File | Sizes | By sizes | deliveryType | Blocking | Server-Timing |
|---|---|---|---|---|---|
| styles.css | 0 / 144 / 3780 | cache | cache | blocking | none |
| blocking.js | 0 / 40 / 40 | cache | cache | blocking | none |
| deferred.js | 0 / 102 / 102 | cache | cache | non-blocking | none |
| photo-tao.svg (other origin) | 0 / 157 / 157 | cache | cache | non-blocking | cdn HIT |
| photo-no-tao.svg (other origin) | 0 / 0 / 0 | hidden | "" | non-blocking | none |
| data.json | 300 / 34 / 34 | revalidated | cache | non-blocking | none |
The page read 6 resource entries. Its navigation type was "navigate". The servers' log shows every request they answered on this visit: the HTML 200, data.json 304.
Render-blocking status and delivery type
Two newer fields say more about a file. Check the support boxes before you rely on them.
Did the file hold up the first paint?
Some files must arrive before the browser paints anything at all. MDN puts it this way: the browser "doesn't render any pixel to the screen before all stylesheets and synchronous scripts are loaded and evaluated." A synchronous script is a plain <script> tag, with no async or defer.
Files like these are called render-blocking. It's like a shop that keeps its door shut until the price tags are up. Render-blocking resources covers them in depth.
renderBlockingStatus marks each file "blocking" or "non-blocking". In the standard's words, a blocking file "can potentially block rendering". Here is what Chrome said:
1// Chrome's renderBlockingStatus for each file on the first visit.2const status = {3 "styles.css": "blocking", "blocking.js": "blocking", "deferred.js": "non-blocking",4 "photo-tao.svg": "non-blocking", "photo-no-tao.svg": "non-blocking", "data.json": "non-blocking",5};6 7console.log(`blocking: ${Object.keys(status).filter((file) => status[file] === "blocking").join(", ")}`);- Lines 3 and 4 hold Chrome's recorded
renderBlockingStatusfor all six files. - Line 7 keeps the ones marked blocking and prints
blocking: styles.css, blocking.js.
Look at the two scripts. blocking.js is a plain <script> in the head, so it blocks. deferred.js has the defer attribute, which tells the browser to run it after reading the HTML, so it doesn't. The photos and data.json were non-blocking too.
Send this field with each slow file's timing. A slow blocking file can delay the first paint of the whole page. A slow non-blocking file, like a photo, doesn't hold up the rest of the page.
Did the cache play a part?
deliveryType answers one question: was the cache involved? The standard gives it two values for now. It's "cache" if the cache mode is anything but empty, and an empty string otherwise. Here is what Chrome reported on the second visit:
1// Chrome's deliveryType for three files on the second visit.2const files = { "styles.css": "cache", "data.json": "cache", "photo-no-tao.svg": "" };3 4for (const [name, type] of Object.entries(files)) console.log(`${name}: "${type}"`);- Line 2 holds three files' recorded
deliveryType. - Line 4 prints
styles.css: "cache",data.json: "cache"andphoto-no-tao.svg: "".
styles.css came straight from the cache, and data.json was revalidated. Yet both say "cache". So deliveryType tells you that the cache played a part, and transferSize tells you which part: 0 or 300. The hidden photo says "", even though the servers' log shows it came from the cache too. For a hidden file, this field stays empty as well.
The other engines differ again. Firefox 155, the version the course used, didn't have deliveryType yet. WebKit had it, but returned an empty string even for its cache hits.
Server-Timing headers
Back to the HTML's wait: 93.4 ms between asking and the first byte. The browser saw the wait, but not what happened inside the server. Only the server knows that. Server-Timing is a header the server uses to tell the browser how long its own steps took.
Your order at the café takes a long time. When the plate comes, a note from the kitchen is stapled to it: "Looked up your order: 6 minutes. Cooked it: 3 minutes." Your watch only saw the wait. The note tells you what happened behind the kitchen door.
- In real life: The note stapled to the plate
- In JavaScript: The
Server-Timingheader on the response - In real life: "Looked up your order: 6 minutes"
- In JavaScript:
db;dur=62.8: one step, and how long it took - In real life: Your own watch
- In JavaScript: The entry's wait,
requestStarttoresponseStart - In real life: Reading the note at your table
- In JavaScript: The entry's
serverTiminglist
The course's server pretended to do two jobs before it sent the HTML: a products query, then building the page. It waited a set time for each, timed both, and sent the times with the HTML:
Server-Timing: db;dur=62.8;desc="Products query", render;dur=28.1Line 1 holds two metrics, separated by a comma. Each metric has up to three parts, separated by semicolons:
- a name:
dbandrender; dur, how long the step took, in milliseconds:62.8and28.1;desc, a description:"Products query". It's optional, andrenderhas none.
The browser reads the header and puts each metric in the entry's serverTiming list. Here is that list next to the wait:
1// Chrome's navigation entry: serverTiming, read from the header above, and the wait it explains.2const serverTiming = [3 { name: "db", duration: 62.8, description: "Products query" },4 { name: "render", duration: 28.1, description: "" },5];6const waited = 97.5 - 4.1; // responseStart - requestStart7const server = serverTiming.reduce((total, metric) => total + metric.duration, 0);8 9console.log(`server work: ${server.toFixed(1)} ms of a ${waited.toFixed(1)} ms wait`);10console.log(`the rest: ${(waited - server).toFixed(1)} ms`);- Lines 3 and 4 hold Chrome's recorded
serverTimingfor the HTML. - Line 6 is the wait the browser measured:
responseStartminusrequestStart. - Line 7 adds up the server's steps.
- Lines 9 and 10 print
server work: 90.9 ms of a 93.4 ms waitandthe rest: 2.5 ms.
The server's two steps explain 90.9 of the 93.4 ms. The other 2.5 ms went on things neither side timed, such as the trip to the server and back. So the fix is in the server, and the products query is the biggest part. Every engine read the same numbers the server had measured, on every visit.
Notice what the note doesn't say: when each step started. The Server Timing standard leaves start times out on purpose. The server's clock and the browser's can't be relied on to agree, so only durations are shared.
Any response can carry the header: a script, an image or a JSON file. For a file from another origin, the rule from earlier applies. The standard keeps the metrics hidden unless Timing-Allow-Origin shares them, "but the user agent MAY keep the same origin policy restriction." The course's photo server sent cdn;desc="HIT" with both photos:
- Chrome and Firefox showed it for the photo sent with the header, and an empty list for the other.
- WebKit showed an empty list for both.
Visible or hidden?
Can the page read the real value of each field below, or does it get 0 or an empty list? Every answer comes from the course's visits.
- Your own stylesheet's connection and request times
- The requestStart of a photo from another origin, sent with Timing-Allow-Origin
- The page's own Server-Timing metrics
- The decodedBodySize of a cached stylesheet, in Chrome
- The requestStart of a photo from another origin, sent without the header
- The transferSize of that same photo
- The Server-Timing of that same photo
- The decodedBodySize of a cached stylesheet, in WebKit
Sort each field by whether the page could read its real value.
Common mix-ups
- "A 0 means the file arrived instantly." No. For a file from another origin without Timing-Allow-Origin, 0 means hidden. The hidden photo took 6.9 ms, but its wait read 0.
- "transferSize includes the real size of the headers." No. It adds a fixed 300 instead, so the real size can't give cookies away.
- "A transferSize of 0 means a cache hit." No. A hidden file reads 0 too. Check that the body sizes aren't 0 as well, and know that in WebKit even that isn't enough.
- "Every browser reports a cache hit the same way." No. Chrome kept the body sizes, WebKit set them to 0, and Firefox reported a revalidated file as 334 bytes.
- "Every file gets an entry on every visit." No. On later visits, Firefox made no entry at all for some files it took from its cache.
- "deliveryType tells a cache hit from a revalidation." No. Both read "cache". transferSize tells them apart.
- "Timing-Allow-Origin always shares Server-Timing." No. The standard lets a browser keep it hidden, and WebKit did.
Practice exercises
Answer first, then use the hints.
The server now takes 100 ms longer to answer. What does the first line print?
// Chrome's navigation entry on the first visit, in ms.
const page = { requestStart: 4.1, responseStart: 197.5, responseEnd: 198.3 };
const wait = page.responseStart - page.requestStart;
const download = page.responseEnd - page.responseStart;
console.log(`Waiting for the server: ${wait.toFixed(1)} ms`);
console.log(`Downloading the HTML: ${download.toFixed(1)} ms`);Waiting for the server: 193.4 ms. The wait grows by the same 100 ms. The download doesn't change, so the second line still says 0.8 ms.
This time data.json's transferSize is 334. What does the second line print?
// Chrome's sizes on the second visit: [transferSize, encodedBodySize, decodedBodySize].
const files = {
"styles.css": [0, 144, 3780],
"data.json": [334, 34, 34],
"photo-no-tao.svg": [0, 0, 0],
};
function delivery([transfer, encoded, decoded]) {
if (transfer === 0 && decoded === 0) return "hidden";
if (transfer === 0) return "cache";
if (transfer === 300) return "revalidated";
return "network";
}
for (const [name, sizes] of Object.entries(files)) console.log(`${name}: ${delivery(sizes)}`);data.json: network. 334 isn't 0 and isn't 300, so the last rule says the file came over the network: 34 bytes of body plus 300.
On Firefox's second visit, how many resource entries could the page read?
4. Firefox made no entries for the two photos it took from its cache. Chrome made 6 on the same visit, one for every file.
On WebKit's second visit, data.json's entry said 334 bytes. Which status did the server send for it?
200. WebKit didn't ask whether its copy was still good. It downloaded data.json again, so its 334 was a real download. Firefox's 334 came with a 304.
This code counts cache hits on the first visit, when nothing came from the cache. What does it print, and which file fooled it?
// Chrome's first visit: [file, transferSize, decodedBodySize] for each file.
const files = [
["styles.css", 444, 3780],
["blocking.js", 340, 40],
["photo-tao.svg", 457, 157],
["deferred.js", 402, 102],
["photo-no-tao.svg", 0, 0],
["data.json", 334, 34],
];
const cacheHits = files.filter(([file, transferSize]) => transferSize === 0);
console.log(`Cache hits: ${cacheHits.length}`);// Chrome's first visit: [file, transferSize, decodedBodySize] for each file.
const files = [
["styles.css", 444, 3780],
["blocking.js", 340, 40],
["photo-tao.svg", 457, 157],
["deferred.js", 402, 102],
["photo-no-tao.svg", 0, 0],
["data.json", 334, 34],
];
const cacheHits = files.filter(([file, transferSize, decodedBodySize]) => transferSize === 0 && decodedBodySize > 0);
console.log(`Cache hits: ${cacheHits.length}`);Cache hits: 1, and it's wrong. The hidden photo reads 0 for every size, so a check on transferSize alone counts it. A real cache hit still knows its body size, so also checking decodedBodySize > 0 prints Cache hits: 0.
Your photos are used by many sites, and you want all of them to read the full times. What value do you send?
Timing-Allow-Origin: *. The * lets any origin see the times. Name your own sites instead when only they need them.
Check your understanding
For each question, ask three things: which field, which origin, and which browser.
Question 1 of 7What does the first line of the wait example print?
Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript// Chrome's navigation entry on the first visit, in ms. const page = { requestStart: 4.1, responseStart: 97.5, responseEnd: 98.3 }; const wait = page.responseStart - page.requestStart; const download = page.responseEnd - page.responseStart; console.log(`Waiting for the server: ${wait.toFixed(1)} ms`); console.log(`Downloading the HTML: ${download.toFixed(1)} ms`);Choose an answer to see the explanation.
Question 2 of 7What does the second line of the sizes example print?
Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript// Chrome's sizes on the second visit: [transferSize, encodedBodySize, decodedBodySize]. const files = { "styles.css": [0, 144, 3780], "data.json": [300, 34, 34], "photo-no-tao.svg": [0, 0, 0], }; function delivery([transfer, encoded, decoded]) { if (transfer === 0 && decoded === 0) return "hidden"; if (transfer === 0) return "cache"; if (transfer === 300) return "revalidated"; return "network"; } for (const [name, sizes] of Object.entries(files)) console.log(`${name}: ${delivery(sizes)}`);Choose an answer to see the explanation.
Question 3 of 7A photo from your CDN reads 0 for requestStart, responseStart and transferSize, yet it shows on the page. What's the likely reason?
Choose an answer to see the explanation.
Question 4 of 7Why does the standard add a fixed 300 to transferSize instead of the headers' real size?
Choose an answer to see the explanation.
Question 5 of 7In WebKit, a cached stylesheet read 0 for all three sizes. What does that mean for code that looks for cache hits?
Choose an answer to see the explanation.
Question 6 of 7On the second visit, Chrome said data.json's deliveryType was "cache". Did data.json skip the network?
Choose an answer to see the explanation.
Question 7 of 7Your CDN sends Server-Timing and Timing-Allow-Origin. Can every browser read its metrics on your page?
Choose an answer to see the explanation.
Key takeaways
performance.getEntriesByType("navigation")gives the page's entry. With"resource", you get one entry per file, your ownfetch()calls included.- Subtract two times for any phase. startTime shows when a file was started; duration shows how long its trip took.
- A file from another origin reads 0 for everything but its start and end, unless it sends Timing-Allow-Origin for your site.
- In Chrome, transferSize is 0 from the cache, 300 for a revalidation, and the body plus 300 from the network.
- Firefox and WebKit report the cache differently, so check which browser sent each size.
- renderBlockingStatus says whether a file could hold up the first paint. deliveryType says whether the cache played a part.
- Server-Timing lets the server say how long its own steps took.
Remember the one-liner.
Every file comes with a delivery note: read its times and its weight, and know which lines another site blacked out.
Sources
The facts on this page come from these standards and docs, and from the course's own visits:
- Performance Timeline, W3C.
- Navigation Timing Level 2, W3C.
- Resource Timing, W3C.
- PerformanceResourceTiming: nextHopProtocol, MDN.
- <script>: the defer attribute, MDN.
- Timing-Allow-Origin header, MDN.
- 304 Not Modified, MDN.
- PerformanceResourceTiming: renderBlockingStatus, MDN.
- PerformanceResourceTiming: deliveryType, MDN.
- Server Timing, W3C.
Coming next: Paint, LCP, and layout shift APIs. You'll read the entries behind FCP, LCP and CLS, plus Element Timing for the elements you care about.