The Network panel for performance
Timing breakdowns, priorities, initiators, request blocking, and HAR files. Each request's phases worked out with DevTools' own rules, on real recordings of the course's site and store, and what each of the panel's controls changes.
- 01Find the request you needFilter, sort and search the requests table, and read its Size, Priority and Initiator columns.
- 02Read one requestOpen its Timing tab, say where the time went, and count each moment only once.
- 03Change one conditionBlock a request, turn off the cache or slow the network, and compare enough loads to trust the difference.
- 04Share safelyExport a HAR file, and know what a sanitized one leaves out and what it keeps.
Every request, in detail
Asha's message is still on your desk: the Leaf and Kettle page is slow on her phone, on the bus. The Performance panel showed you what the main thread did. This time, look at the files the store downloads.
Open DevTools and pick the Network panel. Set its throttling menu to Slow 4G, so your laptop gets data about as slowly as a phone on a weak signal. Then reload. Four lines appear: the page, and the three files it asks for. Each line is a request: the browser asking a server for one file.
The course did exactly that, in Chrome, on a phone-sized screen. Here is what the panel listed:
// The store's first visit on Slow 4G, as the Network panel listed it.const rows = [ { name: "index.html", size: 896, time: 572.8 }, { name: "styles.css", size: 623, time: 583.4 }, { name: "app.js", size: 153417, time: 2288.1 }, { name: "hero.svg", size: 580839, time: 4673.3 },]; for (const row of rows) console.log(`${row.name}: ${row.size.toLocaleString("en-US")} bytes in ${row.time} ms`);- Line 1 is a comment. It says where the numbers come from.
- Lines 3 to 6 hold one file each: its name, its size in bytes, and its time in milliseconds (ms). A millisecond is a thousandth of a second.
- Line 9 prints one sentence per file. The first two are
index.html: 896 bytes in 572.8 msandstyles.css: 623 bytes in 583.4 ms. The last two areapp.js: 153,417 bytes in 2288.1 msandhero.svg: 580,839 bytes in 4673.3 ms.
Look at the first line. The page itself is smaller than a kilobyte, and it still took more than half a second. The main picture, hero.svg, took more than four and a half seconds. So where did those seconds go? What asked for each file? Would Asha's second visit be any faster? And how do you show all this to someone who can help?
Each question has its own part of the Network panel. This picture shows the four parts this lesson uses, and what each is for:
The biggest part is the requests table. It works like a log of deliveries: one row per request, with columns such as Size, Time, Priority and Initiator.
Leaf and Kettle keeps a delivery log. Each line is one parcel: who sent it, how big it was, and how long it took. When the morning's tea arrives late, you don't read every line. You show only the parcels from the tea supplier. Or you put the biggest ones at the top. Or you search the notes for the word "tea".
- In real life: One line in the log
- In JavaScript: One row in the requests table: one request
- In real life: Showing only the parcels from one supplier
- In JavaScript: The filter bar, like
domain:or the JS button - In real life: Putting the biggest parcels at the top
- In JavaScript: Clicking the Size column's header to sort by it
- In real life: Searching the delivery notes for a word
- In JavaScript: The Search tab, which looks inside headers and responses
Finding the rows you need
The store has four rows. The course's own home page made 37 requests when this lesson loaded it. DevTools' Network features reference describes three ways to find the rows you want:
- Filter by type. Buttons such as JS, CSS, Img and Doc show one kind of file. Hold Command on a Mac, or Control elsewhere, to pick more than one.
- Filter by property. Type into the Filter box. Plain text matches the request's address. A word with a colon picks a property, such as
domain:,status-code:,priority:orlarger-than:. Type several, and a row must match them all. - Sort and search. Click a column's header to sort the table by that column. Press Command+F, or Control+F, to open the Search tab. It looks inside each request's headers and responses, not just the names.
Here is the filter larger-than:1k on the store's four rows. The code does by hand what DevTools does:
// The filter box says larger-than:1k. DevTools reads 1k as 1,000 bytes.const rows = [ { name: "index.html", size: 896 }, { name: "styles.css", size: 623 }, { name: "app.js", size: 153417 }, { name: "hero.svg", size: 580839 },];const shown = rows.filter((row) => row.size >= 1000); console.log(`Shown: ${shown.map((row) => row.name).join(" and ")}.`);- Lines 2 to 7 are the four rows again, with their sizes in bytes.
- Line 8 keeps the rows of 1,000 bytes or more. DevTools' source, NetworkLogView.ts, reads
1kas 1,000 and compares with>=, so a row of exactly 1,000 bytes stays. - Line 10 prints
Shown: app.js and hero.svg.
On the store, the two biggest files are also the two slowest, so size is a good first filter for a slow download. Try the recorded store below. Pick a visit, then click a file's name to see its Timing tab. This lesson takes all three visits apart.
| Name | Status | Initiator | Size | Time | Priority |
|---|---|---|---|---|---|
| 200 | Other | 896 B | 572.8 ms | HighestHighest | |
| 200 | index.html:7Parser | 623 B | 583.4 ms | HighestHighest | |
| 200 | index.html:8Parser | 153,417 B | 2,288.1 ms | HighHigh | |
| 200 | index.html:14Parser | 580,839 B | 4,673.3 ms | HighMedium |
4 requests and 735,775 bytes over the network. The load event fired at 5,318.7 ms. app.js took 2,288.1 ms in all. Its longest phase was Content download: 1,698.7 ms.
The Network panel lists every request a page makes. For each one, it shows where the time went and what asked for it. Its controls change one condition at a time, so you can load the page again and compare.
The Timing tab, phase by phase
Click a row, and a set of tabs opens beside the table. One of them is Timing. It takes that one request apart, phase by phase.
You've met the phases in Reading a waterfall, as a trip to collect a parcel. Looking up the address is the DNS lookup. Driving there is Initial connection. Waiting at the counter is Waiting for server response. Carrying the boxes out is Content download. Before them come Queueing and Stalled, when the request waits its turn.
The store came from a server on the course's own computer, so its requests made no real trip. For a real one, the course loaded its own site, https://completefrontend.com/, over the internet: five times, in a fresh browser each time, keeping the load in the middle. Here is the Timing tab of the site's HTML, 131,989 bytes over HTTP/2, with each phase as wide as its time:
Before you read the numbers, one surprise. Add up all eight phases, and you get more than the request took:
// The course site's HTML, phase by phase, from its Timing tab, in ms.const phases = { queueing: 0.4, stalled: 19.9, dns: 19.9, connecting: 47.3, ssl: 26.3, sending: 0.1, waiting: 21.7, receiving: 55.4 };const total = 144.9;const add = (names) => names.reduce((sum, name) => sum + phases[name], 0).toFixed(1); console.log(`All eight phases add up to ${add(Object.keys(phases))} ms, but the request took ${total} ms.`);console.log(`Leave out DNS lookup and SSL, and they add up to ${add(["queueing", "stalled", "connecting", "sending", "waiting", "receiving"])} ms.`);- Line 2 holds the eight phases, as the Timing tab showed them.
- Line 3 is the whole request: 144.9 ms.
- Line 4 makes a small helper,
add. It adds up the phases you name. - Line 6 adds all eight. It prints
All eight phases add up to 191.0 ms, but the request took 144.9 ms. - Line 7 leaves two out. It prints
Leave out DNS lookup and SSL, and they add up to 144.8 ms.
Two phases sit inside others, so adding everything counts them twice:
- SSL is inside Initial connection. SSL is DevTools' name for TLS, the step that makes the connection private. DevTools' reference says Initial connection is the browser "establishing a connection, including TCP handshakes or retries and negotiating an SSL."
- Stalled covers the DNS lookup here. In DevTools' source, RequestTimeRanges.ts, Stalled runs from the start of the request to the first of its steps that starts after zero. Chrome reported this DNS lookup starting at exactly zero. So Stalled ran on to the start of the connection, right over the lookup.
It's like a school timetable where choir practice sits inside the lunch break. Both are written down, but the day doesn't get longer. So count each moment once: read the six phases in order, and leave out DNS lookup and SSL. They add up to 144.8 ms, the whole request give or take rounding.
Now you can ask where the time went:
// The same request. Stalled covers the DNS lookup, and Initial connection covers SSL.const phases = { queueing: 0.4, stalled: 19.9, dns: 19.9, connecting: 47.3, ssl: 26.3, sending: 0.1, waiting: 21.7, receiving: 55.4 };const total = 144.9;const settingUp = phases.stalled + phases.connecting; console.log(`Setting up the connection: ${Math.round((settingUp / total) * 100)}% of the request.`);console.log(`Waiting for the server: ${phases.waiting} ms.`);console.log(`Downloading the HTML: ${phases.receiving} ms.`);- Line 1 is a comment. It says which phase covers which.
- Line 4 adds Stalled and Initial connection. Together they hold the DNS lookup, TCP and TLS: everything it took to set up the connection.
- Line 6 prints
Setting up the connection: 46% of the request. - Lines 7 and 8 print
Waiting for the server: 21.7 ms.andDownloading the HTML: 55.4 ms.
Almost half of this request went on setting up a new connection, before the browser could even ask for the page. The server itself answered in 21.7 ms. So on a first visit, a slow request isn't always a slow server. Read the phases before you decide what to fix. DNS lookups, TCP connections and TLS each have a lesson on making the setup shorter.
Priority and initiator columns
Back to the store. Two columns tell you how Chrome treated each request. Initiator is in the table from the start. To add Priority, right-click the table's header and tick it.
The Priority column shows how urgent Chrome thought each file was. How browsers prioritize requests showed where those priorities come from. Chrome reports five levels, from VeryLow to VeryHigh. DevTools shows them under its own names, from Lowest to Highest.
A priority can change while a file loads. Turn on Big request rows in the panel's settings, and each row grows to two lines. DevTools' docs say the Priority column then "shows both the initial (bottom value) and final (top value) fetch priority."
// The store's first visit: each file's priority when Chrome asked for it, and at the end.const requests = [ { file: "index.html", first: "VeryHigh", last: "VeryHigh" }, { file: "styles.css", first: "VeryHigh", last: "VeryHigh" }, { file: "app.js", first: "High", last: "High" }, { file: "hero.svg", first: "Medium", last: "High" },];const label = { VeryLow: "Lowest", Low: "Low", Medium: "Medium", High: "High", VeryHigh: "Highest" }; for (const { file, first, last } of requests) console.log(first === last ? `${file}: ${label[last]}` : `${file}: ${label[last]}, raised from ${label[first]}`);- Lines 3 to 6 hold the store's four requests on the first visit. Each has the priority Chrome gave it when it asked, and the last one.
- Line 8 is DevTools' table of names. Chrome's
VeryHighshows as Highest. - Line 10 prints one line per file:
index.html: Highest,styles.css: Highestandapp.js: High. Thenhero.svg: High, raised from Medium.
The main picture was raised. Why? web.dev explains how Chrome treats images. "Images start at 'Low' priority by default. At layout time, images within the viewport are boosted to 'High'." The viewport is the part of the page you can see. And "as of Chrome 117, the first 5 large images are set to 'Medium'". Large means over 10,000 square pixels.
The store's main picture is 360 by 270 pixels, which is 97,200 square pixels. So it started at Medium. Once Chrome laid the page out and saw the picture on screen, it raised it to High. It's like a parcel moved to express at the sorting office, once someone sees it's needed today.
Why it matters: a low priority means Chrome decided a file could wait. When a request waits its turn, its Timing tab shows Queueing, and the first reason DevTools' docs give is "There are higher priority requests."
The Initiator column says what asked for each file. Reading a waterfall called it the "ordered by" line on a delivery slip. When the HTML asked for a file, the column names the HTML file and the line, as a link.
// What asked for each file, as Chrome reported it. Chrome counts lines from 0.const requests = [ { file: "index.html", type: "other" }, { file: "styles.css", type: "parser", url: "index.html", lineNumber: 6 }, { file: "app.js", type: "parser", url: "index.html", lineNumber: 7 }, { file: "hero.svg", type: "parser", url: "index.html", lineNumber: 13 },]; for (const { file, type, url, lineNumber } of requests) console.log(type === "parser" ? `${file}: asked for by ${url}:${lineNumber + 1}` : `${file}: Other`);- Lines 3 to 6 hold what Chrome reported for each request.
parsermeans Chrome's HTML parser found the file's name while reading the HTML. - Line 9 adds 1 to each line number. Chrome counts lines from 0, and DevTools shows them from 1, the way your editor does.
- It prints
index.html: Other, thenstyles.css: asked for by index.html:7,app.js: asked for by index.html:8andhero.svg: asked for by index.html:14.
Open the store's HTML and go to line 14: it's the picture's <img> tag. index.html itself shows Other, because nothing on a page asked for it. DevTools' docs use Other for actions such as typing an address. When a file starts late, its initiator is the first place to look.
Blocking requests to test their cost
In The Performance panel, you blocked the store's main picture from the Request conditions drawer, a panel along the bottom of DevTools. In the Network panel, right-click a request and choose Block request to open it.
Blocking is a natural way to price a third party: code from another company, loaded from its server, like the store's chat widget from Reading a performance trace. Block its file, load the page again, and compare. But one load can fool you.
The course loaded the store with the chat widget twenty times. Ten loads were as normal, and ten had widget.js blocked. They took turns, one of each. The CPU ran 4 times slower, so the laptop behaved more like a phone. Each time, the course measured how long the main thread, the part of the browser that runs the page's JavaScript, was busy in the first three seconds:
// The store with its chat widget: the main thread's busy time in each load's first three seconds, in ms.const withWidget = [709.5, 700, 804.1, 829.9, 703.3, 815.2, 817, 799.4, 734.6, 808.7];const blocked = [583, 554.2, 690.1, 584.9, 568.1, 562.8, 588.6, 580.2, 585.5, 567.1];const middle = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)]; console.log(`With widget.js: ${middle(withWidget)} ms busy, in the middle load.`);console.log(`Blocked: ${middle(blocked)} ms busy. That's ${(middle(withWidget) - middle(blocked)).toFixed(1)} ms less.`);- Line 2 holds the ten loads with the widget. Line 3 holds the ten with widget.js blocked.
- Line 4 makes
middle. It sorts a list and takes the value in the middle, so one odd load can't move it much. - Lines 6 and 7 print
With widget.js: 804.1 ms busy, in the middle load.andBlocked: 583 ms busy. That's 221.1 ms less.
So the widget costs about 221 ms? Check that against the widget itself. Blocked, widget.js never ran. In the middle load with it, its script took 110.3 ms, as one long task. So, comparing every load, blocking seemed to save twice what the widget's script took. Where did the rest come from?
You want to know whether the back lane to the tea shop is quicker than the main road. Both cross the railway, and on some mornings the barrier is down for ten minutes. Time one walk each way, and the barrier decides the answer, not the route. So you walk each way on ten mornings, and compare only the mornings when the barrier was up.
- In real life: The two routes
- In JavaScript: Loads with widget.js, and loads with it blocked
- In real life: The barrier, down on some mornings
- In JavaScript: The store's own sort, slow in some loads
- In real life: Ten mornings each way
- In JavaScript: Ten loads each way, taking turns
- In real life: Comparing mornings when the barrier was up
- In JavaScript: Comparing loads whose sorts ran alike
Here the barrier was the store's own code. As the page loads, the store sorts 200,000 old order prices to build a search index. The course timed that sort in every load, from the trace's JavaScript samples:
// The store's own sort, in each of the same twenty loads, in ms.const withWidget = [17.7, 14.7, 142.5, 141, 11.2, 145, 147.2, 137.2, 14.9, 137.7];const blocked = [18.4, 18.9, 147.5, 19.5, 11.3, 12.7, 29.3, 16.2, 16.8, 13.6];const slow = (list) => list.filter((ms) => ms >= 50).length; console.log(`Slow sorts with widget.js: ${slow(withWidget)} of 10.`);console.log(`Slow sorts with widget.js blocked: ${slow(blocked)} of 10.`);- Lines 2 and 3 hold the sort's time in each load, in the same order as before.
- Line 4 counts the slow ones: 50 ms or more.
- Lines 6 and 7 print
Slow sorts with widget.js: 6 of 10.andSlow sorts with widget.js blocked: 1 of 10.
In seven loads the store's sort, the same code on the same data, took about 140 ms instead of about 15. More of those came with widget.js, but twenty loads can't tell you whether the widget caused them. So compare like with like: only the loads whose sorts ran fast.
// Each load as [busy time, sort time], in ms. A sort under 50 ms is a fast one.const withWidget = [[709.5, 17.7], [700, 14.7], [804.1, 142.5], [829.9, 141], [703.3, 11.2], [815.2, 145], [817, 147.2], [799.4, 137.2], [734.6, 14.9], [808.7, 137.7]];const blocked = [[583, 18.4], [554.2, 18.9], [690.1, 147.5], [584.9, 19.5], [568.1, 11.3], [562.8, 12.7], [588.6, 29.3], [580.2, 16.2], [585.5, 16.8], [567.1, 13.6]];const middle = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)];const fastOnly = (loads) => loads.filter(([, sort]) => sort < 50).map(([busy]) => busy); console.log(`Fast sorts only: ${middle(fastOnly(withWidget))} ms with widget.js, ${middle(fastOnly(blocked))} ms blocked.`);console.log(`That's ${(middle(fastOnly(withWidget)) - middle(fastOnly(blocked))).toFixed(1)} ms less.`);- Lines 2 and 3 hold each load as a pair: its busy time, then its sort time.
- Line 5 keeps the loads whose sort took under 50 ms, and takes their busy times.
- Line 7 prints
Fast sorts only: 709.5 ms with widget.js, 580.2 ms blocked. - Line 8 prints
That's 129.3 ms less.
129.3 ms is a little more than the widget's own 110.3 ms. That's a believable price for the widget. The first answer, 221.1 ms, had the slow sorts mixed in. Here are all twenty loads, sorted into lanes:
Why it matters: one load each way can make a script look twice as costly as it is. Record several loads each way, taking turns. Then check what else changed before you credit the block.
Disabling cache and throttling
Asha has visited the store before. Will her next visit be faster? To answer, you need to know which bytes came over the network. That's the Size column.
What the Size column counts
DevTools' reference defines Size as "the combined size of the response headers plus the response body, as delivered by the server." The headers are short lines of information sent before the file, such as its type. The body is the file itself, as it travelled.
You order a duvet. It arrives as a small, flat parcel, because the shop squeezed it into a vacuum bag. The size on the label is what the van carried. Open the bag, and the duvet puffs up to fill the bed. Both sizes are true. They answer different questions.
- In real life: The size on the parcel's label, box and all
- In JavaScript: The bytes that came over the network: headers plus the body as sent
- In real life: The box and its label
- In JavaScript: The response's headers
- In real life: Squeezing the duvet into a vacuum bag
- In JavaScript: Compression, such as gzip or Brotli
- In real life: The duvet once unpacked
- In JavaScript: The resource size: the file after the browser unpacks it
Turn on Big request rows, and the Size column shows both sizes. The top number is what came over the network. The bottom one is the resource size: the file unpacked. Here are the two for the store's app.js:
// app.js on the store's first visit, in bytes.const unpacked = 153208; // the file itself, as the server stores itconst transferred = 153417; // what came over the network for it console.log(`Over the network: ${transferred.toLocaleString("en-US")} bytes.`);console.log(`Unpacked: ${unpacked.toLocaleString("en-US")} bytes.`);console.log(`The difference: ${transferred - unpacked} bytes, mostly the response's headers.`);- Line 2 is app.js as it sits on the server: 153,208 bytes.
- Line 3 is what came over the network for it.
- Lines 5 to 7 print
Over the network: 153,417 bytes.,Unpacked: 153,208 bytes.andThe difference: 209 bytes, mostly the response's headers.
The store's server sends its files as they are, so the two sizes are almost the same. The label adds a little: 195 of the 209 bytes are the response's headers. A server can also compress a text file: squeeze it before sending it, so the browser unpacks it when it arrives. Then the two sizes are far apart. DevTools' docs show a google.com page that was 43.8 KB over the network and 136 KB unpacked. gzip, Brotli, and Zstandard covers compression. Why it matters: the top number is what a phone has to download, and the bottom one is what the browser has to read.
A file that was already on the shelf
Now the second visit. With each file, the store's server sends a Cache-Control header, which tells the browser how it may keep a copy. MDN explains the two values the store uses:
max-age=31536000, on the three other files, is a year in seconds. Withmax-age=N, a response "remains fresh until N seconds after the response is generated." Until then, the browser uses its copy without asking.no-cache, on the HTML, means the response "must be validated with the origin server before each reuse." The browser keeps a copy, but checks it every time.
The course loaded the store three times in a row, in one tab, on Slow 4G: a first visit, a plain reload, and a reload with Disable cache checked.
const visits = { "First visit": { load: 5318.7, transferred: 735775 }, Reload: { load: 656.7, transferred: 228 }, "Reload, Disable cache": { load: 5318.5, transferred: 735775 },}; for (const [visit, { load, transferred }] of Object.entries(visits)) { console.log(`${visit}: ${transferred.toLocaleString("en-US")} bytes, load event at ${load} ms`);}- Lines 2 to 4 hold each visit's bytes over the network, and when its load event fired. The load event fires once the page and all its files have arrived.
- Lines 7 to 9 print one line per visit. The first two are
First visit: 735,775 bytes, load event at 5318.7 msandReload: 228 bytes, load event at 656.7 ms. The last isReload, Disable cache: 735,775 bytes, load event at 5318.5 ms.
The reload moved 228 bytes instead of 735,775, and its load event came about eight times sooner. How?
Before you order more tea, you check the shelf. If a fresh packet is already there, nobody has to deliver anything. If the packet might be old, you phone the supplier: "Is batch 12 still your latest?" "Yes, keep it." The call is short, but you still wait for the answer.
- In real life: A fresh packet already on the shelf
- In JavaScript: A file from the cache: no request on the network
- In real life: Phoning to ask if your batch is still the latest
- In JavaScript: Checking the copy with the server, using its ETag
- In real life: "Yes, keep it"
- In JavaScript:
304 Not Modified: headers, and no body - In real life: Pretending the shelf is empty
- In JavaScript: The Disable cache checkbox
The reload did both. Its three year-long files showed (memory cache) in the Size column. Chrome still held a copy of each in memory from the first visit, in the same tab. No request went out, and their Timing tabs held only zeros. The browser's caches compares this memory cache with the HTTP cache, which DevTools labels (disk cache).
The HTML was checked with the server. On the first visit, the server had given it an ETag: in MDN's words, "an identifier for a specific version of a resource", like a batch number. The browser sent it back, it matched, and the server answered 304 Not Modified. MDN says a 304 "must not contain a body". So the reload's 228 bytes were just the 304's headers.
A 304 still isn't free. The HTML waited 579.8 ms for the server's answer, a whole trip on Slow 4G. A year-long max-age saves the trip. no-cache saves only the download. Read this table across: each row is one thing the Size column can show.
| When the Size column says | What happened | Did bytes come over the network? |
|---|---|---|
| A number of bytes | The file came from the server. | Yes: its headers and its body, as the server sent them. |
(memory cache) | Chrome used a copy it was still holding in memory. | No. Its Timing tab holds only zeros. |
(disk cache) | Chrome used a copy from its HTTP cache. | No. |
A small number, with the status 304 | The browser checked its copy with the server, and the copy was still good. | Only the answer's headers. |
The third load had Disable cache checked. DevTools' reference says: "To emulate how a first-time user experiences your site, check the Disable cache checkbox." Its tooltip adds a limit: "Disable cache while DevTools is open." So the reload was a first visit again, at 735,775 bytes. Check the box to test a first visit. Clear it to test a returning visitor, like Asha.
Where throttling's delay lands
Every load above ran on Slow 4G. The Performance panel showed that DevTools' presets add a delay to each whole request, while a real network delays each small packet. Lighthouse's docs warn that "this throttling isn't highly accurate."
So where does the delay show up in the Timing tab? The course loaded the store once more with no throttling, and compared app.js. Here, latency means delay: time spent waiting before any data moves.
// app.js's last two phases, with no throttling and on Slow 4G, in ms.const appJs = { "no throttling": { waiting: 1.5, receiving: 0.3 }, "Slow 4G": { waiting: 586.3, receiving: 1698.7 },};const addedLatency = 562.5; // DevTools' Slow 4G adds this to every request for (const [network, { waiting, receiving }] of Object.entries(appJs)) console.log(`${network}: waiting ${waiting} ms, download ${receiving} ms`);console.log(`${Math.round((addedLatency / appJs["Slow 4G"].waiting) * 100)}% of Slow 4G's wait is the latency DevTools added`);- Lines 3 and 4 hold app.js's last two phases, with no throttling and on Slow 4G.
- Line 6 is the delay Slow 4G adds to every request: 562.5 ms.
- Line 8 prints
no throttling: waiting 1.5 ms, download 0.3 msandSlow 4G: waiting 586.3 ms, download 1698.7 ms. - Line 9 prints
96% of Slow 4G's wait is the latency DevTools added.
It's like a sorting office that holds every parcel for half a second, however small. So under throttling, Waiting for server response is mostly DevTools' delay, not your server. The connection got none of the delay: on Slow 4G, app.js's DNS lookup took 0.2 ms and its Initial connection 0.3 ms. Why it matters: read a throttled Waiting phase as the preset's delay plus your server, and time your server without throttling.
Now try the cache yourself. This playground runs in your browser, on this site's photo of mint tea. Open the Network panel beside it to watch the same request appear there.
// Skip the HTTP cache, or use what it has.const response = await fetch(file, { cache: skipCache ? "no-store" : "force-cache" });await response.blob();const entry = performance.getEntriesByName(new URL(file, location.href).href).at(-1); const phases = { "DNS lookup": entry.domainLookupEnd - entry.domainLookupStart, "Initial connection": entry.connectEnd - entry.connectStart, "Waiting for server response": entry.responseStart - entry.requestStart, "Content download": entry.responseEnd - entry.responseStart,};const from = entry.transferSize === 0 ? "the cache" : `the network, ${entry.transferSize} bytes`;not yetEach button fetches this site's photo of mint tea, then reads its Resource Timing entry. Open DevTools' Network panel beside it to watch the same request appear there.
Line 2 of the panel's code picks the cache option of fetch(). In MDN's words, no-store fetches "without first looking in the cache", and force-cache uses a copy that's "fresh or stale". Line 12 reads transferSize, the bytes that came over the network. It's 0 "if the resource was retrieved from a local cache without contacting the server."
Exporting and sharing HAR files
You've found something, and you want a colleague to see it. A screenshot shows one tab of one request. A HAR file holds the whole list.
HAR stands for HTTP Archive. It's a file, in JSON format, holding every request in the panel with its headers and timings. To save one, click Export HAR in the bar along the top. Your colleague drags the file into their own Network panel, or clicks Import HAR, and sees what you saw.
The tea supplier asks why its parcels keep arriving late. You copy the delivery log and post it to them. First, you black out the shop's door code, which the drivers wrote on a few lines. Everything else is still there: the addresses, the times and the notes.
- In real life: The copy of the log
- In JavaScript: A HAR file: every request, with headers and timings
- In real life: Blacking out the door code
- In JavaScript: Sanitizing: leaving out Cookie, Set-Cookie and Authorization
- In real life: The addresses and notes, still readable
- In JavaScript: URLs, other headers and response contents, still in the file
By default, DevTools exports a sanitized HAR. Its docs say the sanitized log "excludes sensitive information such as Cookie, Set-Cookie, and Authorization headers." A cookie is a small piece of text a site stores in your browser. The browser sends it back to that site with its requests, so it can say who you are.
// Three of app.js's request headers, as the HAR file holds them.const headers = ["Accept: */*", "Cookie: visitor=leaf-1234", "Sec-Fetch-Dest: script"];// The headers DevTools leaves out of a sanitized HAR.const hidden = ["authorization", "cookie", "set-cookie"];const kept = headers.filter((header) => !hidden.includes(header.split(":")[0].toLowerCase())); console.log(`Sanitized, the request keeps: ${kept.join(", ")}.`);- Line 2 holds three of app.js's request headers, from the recorded HAR. One of them carries the store's cookie.
- Line 4 is the list of headers DevTools leaves out, from its source file Log.ts.
- Line 5 keeps every header whose name isn't on that list.
- Line 7 prints
Sanitized, the request keeps: Accept: */*, Sec-Fetch-Dest: script.
Each entry also has lists of cookies, and sanitizing empties those too. In the store's first visit, the cookie's value appeared 8 times in the HAR: in three Cookie headers, one Set-Cookie header, and the cookie lists. Sanitized, it appeared 0 times.
Now the other half. What does a sanitized HAR keep?
// app.js's entry in the sanitized HAR, shortened.const entry = { request: { url: "app.js", headers: ["Accept: */*", "Sec-Fetch-Dest: script"], cookies: [] }, response: { status: 200, headers: ["cache-control: max-age=31536000", "content-type: text/javascript"], cookies: [] }, timings: { wait: 586.3, receive: 1698.7 },}; console.log(`Still there: the address ${entry.request.url}, the status ${entry.response.status}, and a wait of ${entry.timings.wait} ms.`);- Lines 3 to 5 hold the entry's request, response and timings after sanitizing, shortened.
- Line 8 prints
Still there: the address app.js, the status 200, and a wait of 586.3 ms.
Everything but those three headers and the cookie lists stays: addresses with their query strings, every other header, and the timings. DevTools' Writer.ts also adds each response's content, sanitized or not. So if a page fetched a customer's order from an API, that order is in the file.
Why it matters: read a HAR before you post it anywhere public. A secret in an address, or in a header DevTools doesn't know about, goes out with it. If your colleague does need the cookies, one setting lets you export them too. The exercises ask you to find it.
Which part of the panel?
Each question below has one part of the panel that answers it. Ask yourself: is it about one request's time, about what the table's columns say, or about what changes when the conditions do?
- Was this request slow because of the server, or the download?
- How long did setting up the private connection take?
- Which line of the HTML asked for this file?
- Did Chrome raise the main picture's priority after layout?
- How big is app.js once it's unpacked?
- How much main-thread time does the chat widget cost?
- What does a first-time visitor download?
- How does the page load on a slow phone network?
Sort each question by the part of the Network panel that answers it.
Common mix-ups
- "Waiting is the server's time." No. It includes a round trip. On Slow 4G, 96% of app.js's wait was the delay DevTools added.
- "The phases add up to the total." No. Stalled can cover the DNS lookup, and Initial connection covers SSL. Count each moment once.
- "The Size column is the file's size." No. It's what came over the network: the headers plus the body as sent, which may be compressed. Big request rows show the unpacked size under it.
- "A 304 is free." No. It skips the download, not the trip. The reload's HTML still waited 579.8 ms for its answer.
- "One load with a request blocked shows its cost." No. In some loads the store's own sort ran ten times slower, and that moved the totals more than the widget did.
- "Throttling slows everything like a real phone network." No. It adds a delay to each request. On Slow 4G, app.js's connection took under a millisecond.
- "A sanitized HAR is safe to post anywhere." No. It drops cookies and Authorization headers, not addresses, other headers or response contents.
Practice exercises
Answer first, then use the hints.
Here is the same request, on a connection that was already open. What does the first line print?
// The same request, on a connection that was already open. Stalled covers the DNS lookup, and Initial connection covers SSL.
const phases = { queueing: 0.4, stalled: 0.2, dns: 0, connecting: 0, ssl: 0, sending: 0.1, waiting: 21.7, receiving: 55.4 };
const total = 77.8;
const settingUp = phases.stalled + phases.connecting;
console.log(`Setting up the connection: ${Math.round((settingUp / total) * 100)}% of the request.`);
console.log(`Waiting for the server: ${phases.waiting} ms.`);
console.log(`Downloading the HTML: ${phases.receiving} ms.`);Setting up the connection: 0% of the request. The connection was already open, so there was nothing to look up and nothing to set up. Only the wait and the download are left.
The filter box now says larger-than:200k. What does the code print?
// The filter box says larger-than:200k. DevTools reads 200k as 200,000 bytes.
const rows = [
{ name: "index.html", size: 896 },
{ name: "styles.css", size: 623 },
{ name: "app.js", size: 153417 },
{ name: "hero.svg", size: 580839 },
];
const shown = rows.filter((row) => row.size >= 200000);
console.log(`Shown: ${shown.map((row) => row.name).join(" and ")}.`);Shown: hero.svg. Only the main picture has 200,000 bytes or more. app.js is big, but not that big.
In the first playground, pick the reload with Disable cache checked, then hero.svg. How long was its longest phase, in ms?
4,072.6 ms, in Content download. With the cache off, the 580,839-byte picture came over Slow 4G again, and most of its time went on the download.
In the first visit, how long did styles.css spend in Waiting for server response, in ms?
580.2 ms. styles.css is only 623 bytes, but on Slow 4G every request waits for the delay DevTools adds, 562.5 ms. Almost all of its wait was that delay.
With fetchpriority="high" on the picture's tag, line 6 changes. What does the last line print?
// The store's first visit: each file's priority when Chrome asked for it, and at the end.
const requests = [
{ file: "index.html", first: "VeryHigh", last: "VeryHigh" },
{ file: "styles.css", first: "VeryHigh", last: "VeryHigh" },
{ file: "app.js", first: "High", last: "High" },
{ file: "hero.svg", first: "High", last: "High" },
];
const label = { VeryLow: "Lowest", Low: "Low", Medium: "Medium", High: "High", VeryHigh: "Highest" };
for (const { file, first, last } of requests) console.log(first === last ? `${file}: ${label[last]}` : `${file}: ${label[last]}, raised from ${label[first]}`);hero.svg: High. web.dev says a priority hint in the markup lets the image start at High, so Chrome has nothing to raise after layout.
Which DevTools setting must you turn on before DevTools offers a HAR export with the cookies in it?
Allow to generate HAR with sensitive data, in Settings, Preferences, Network. Once it's on, the Export menu offers Export HAR (with sensitive data). Keep that file private.
Check your understanding
For each question, ask which phase or column holds the answer, and what else might have changed between the loads.
Question 1 of 8What does the first line of the where-the-time-went example print?
Read the code, then predict// The same request. Stalled covers the DNS lookup, and Initial connection covers SSL. const phases = { queueing: 0.4, stalled: 19.9, dns: 19.9, connecting: 47.3, ssl: 26.3, sending: 0.1, waiting: 21.7, receiving: 55.4 }; const total = 144.9; const settingUp = phases.stalled + phases.connecting; console.log(`Setting up the connection: ${Math.round((settingUp / total) * 100)}% of the request.`); console.log(`Waiting for the server: ${phases.waiting} ms.`); console.log(`Downloading the HTML: ${phases.receiving} ms.`);Choose an answer to see the explanation.
Question 2 of 8On a new connection, why can Stalled and DNS lookup show the same time?
Choose an answer to see the explanation.
Question 3 of 8What does the last line of the throttling example print?
Read the code, then predict// app.js's last two phases, with no throttling and on Slow 4G, in ms. const appJs = { "no throttling": { waiting: 1.5, receiving: 0.3 }, "Slow 4G": { waiting: 586.3, receiving: 1698.7 }, }; const addedLatency = 562.5; // DevTools' Slow 4G adds this to every request for (const [network, { waiting, receiving }] of Object.entries(appJs)) console.log(`${network}: waiting ${waiting} ms, download ${receiving} ms`); console.log(`${Math.round((addedLatency / appJs["Slow 4G"].waiting) * 100)}% of Slow 4G's wait is the latency DevTools added`);Choose an answer to see the explanation.
Question 4 of 8You block a third-party script, record one load, and the page got slower. What do you conclude?
Choose an answer to see the explanation.
Question 5 of 8What does a sanitized HAR export leave out?
Choose an answer to see the explanation.
Question 6 of 8In the recording, what did the reload get for the HTML, which the server sent with no-cache and an ETag?
Choose an answer to see the explanation.
Question 7 of 8With Big request rows, a row's Size column shows 43.8 KB on top and 136 KB under it. What does that tell you?
Choose an answer to see the explanation.
Question 8 of 8A row's Size column says (memory cache). What happened to that request?
Choose an answer to see the explanation.
Key takeaways
- The requests table is a delivery log: filter it, sort it and search it to find the rows that matter.
- The Timing tab splits one request into phases. Some sit inside others, so count each moment once.
- On a new connection, setting up can be half of a request.
- Priority says how urgent Chrome thought a file was, and it can change after layout. Initiator says what asked for it.
- Block a request to price it, over several loads each way, comparing loads that are alike.
- Size is what came over the network. A cached file moves no bytes; a 304 still costs a trip.
- Throttling adds a delay to each request, and it lands mostly in Waiting.
- A sanitized HAR drops cookies and Authorization headers, but not addresses or contents.
Remember the one-liner.
Find the request, see where its time went, change one condition, and compare enough loads to trust the difference.
Sources
The facts on this page come from these docs and source files:
- Network features reference, Kayce Basques and Sofia Emelianova, Chrome DevTools.
- NetworkLogView.ts, the filters, devtools-frontend source.
- RequestTimeRanges.ts, devtools-frontend source.
- NetworkRequest.ts, devtools-frontend source.
- NetworkPriorities.ts, devtools-frontend source.
- Optimize resource loading with the Fetch Priority API, web.dev.
- Request conditions, Ewa Gasperowicz, Chrome DevTools.
- NetworkDataGridNode.ts, the Size column, devtools-frontend source.
- Cache-Control, MDN.
- ETag, MDN.
- 304 Not Modified, MDN.
- NetworkPanel.ts, devtools-frontend source.
- Network throttling, Lighthouse docs.
- Request: cache property, MDN.
- PerformanceResourceTiming: transferSize property, MDN.
- Log.ts, the HAR export, devtools-frontend source.
- Writer.ts, the HAR export, devtools-frontend source.
Coming next: Rendering and runtime tools. The Network panel showed what the page downloaded. The next lesson shows what it draws: paint flashing, layout shift regions, layers, the performance monitor, coverage and selector stats.