Reading a waterfall
Read every bar, color and gap of a page load: phases, initiators, connections, the CPU and bandwidth rows, and the five problems they reveal, on real recorded waterfalls.
- 01Read a rowName each part of a request's bar, from queueing to download, and say what each part was waiting for.
- 02Follow the storyTrace who asked for each file, see which requests shared a connection, and read the CPU and bandwidth rows under the bars.
- 03Spot the problemTell a slow server, a request chain, new connections, too many bytes and a busy main thread apart by their shapes.
One row per request
Asha opens the Mint tea page at Leaf and Kettle again. You built that page, and you want to see her visit the way the browser saw it. So you open DevTools, pick the Network panel, and reload. A list fills up, one line per file. Next to each line is a colored bar.
That chart of bars is called a waterfall. Its bars step down the page from left to right, a little like water falling down rocks. At first it looks like a wall of colors. But every bar answers two plain questions. When did the browser ask for this file? And when did the file finish arriving?
This version of the tea page has five files. Here are the two times Chrome recorded for each one:
// The five files of the Mint tea page, in the order Chrome asked for them.// Every time is in milliseconds (ms) after the visit started.const rows = [ ["index.html", 0.4, 152.9], ["styles.css", 156.4, 308.6], ["app.js", 156.6, 308.7], ["tea.svg", 156.6, 308.8], ["menu.json", 316.3, 469.5],]; for (const [file, asked, done] of rows) console.log(`${file}: asked for at ${asked} ms, done at ${done} ms`);- Lines 1 and 2 are comments. Every time is counted in milliseconds (ms) from the moment the visit started. A millisecond is a thousandth of a second.
- Lines 4 to 8 hold one file each: its name, when Chrome asked for it, and when its last byte arrived.
- Line 11 prints one sentence per file. The first is
index.html: asked for at 0.4 ms, done at 152.9 ms. The last ismenu.json: asked for at 316.3 ms, done at 469.5 ms.
Read the times from top to bottom. The browser asked for the page first. Then it asked for three files at once, 3.5 ms after the page was done. The last file, menu.json, waited until 316.3 ms. And every file took about 150 ms, however small it was. Why the wait, and why 150? It's easier to see in a picture. Here is the same visit, drawn as a waterfall:
Now the shape tells the story:
- The page comes first. Until it arrives, the browser doesn't know about any other file.
- Three bars start together. The HTML names styles.css, app.js and tea.svg. So the browser asked for all three as soon as it read their names.
- menu.json starts later. The HTML doesn't name it. app.js asks for it, so app.js had to arrive and run first.
- Every bar is about 150 ms long. The course served this page over a slow network that it imitated. Each answer came back 150 ms after it was asked for. That's one round trip: a message to the server, and its reply back.
Leaf and Kettle keeps a delivery board on the wall. Each row is one delivery, with a bar from when the van left to when the parcel arrived. One glance shows which vans went out together, which one had to wait for another to come back, and where the morning went.
- In real life: One row on the board
- In JavaScript: One request
- In real life: The bar, from the van leaving to the parcel arriving
- In JavaScript: From asking for a file to its last byte
- In real life: Vans that leave together
- In JavaScript: Files the browser asked for at the same time
- In real life: A van that can't leave until another is back
- In JavaScript: A file asked for only after another arrived, like
menu.json
A waterfall is the timetable of a page load: one row per request, with time going right. Read each bar to see what that request waited for. Read the gaps between the bars to see what the whole page waited for.
Every waterfall has the same parts, whichever tool draws it:
- One row per request, in the order the browser asked for them.
- Time runs left to right. A bar starts when the browser asks for a file, and ends with its last byte.
- A bar has parts. Each colored part is one phase of the request, such as waiting for the server or downloading. The next section names them all.
- Vertical lines mark moments in the load, such as the first paint and the load event.
- Rows under the bars, in some tools, show what the main thread and the network were doing.
The tea page has five rows, so you can read it at a glance. The course's own home page has 36. The next sections take a waterfall apart one piece at a time: the bars, who asked for each file, the connections, the gaps, and the rows under the bars. They use the home page, and a few versions of the tea page that each show one thing. Then you'll use all of them to diagnose five slow loads.
Lesson 5.1, The navigation timeline, timed one request, the page itself, job by job. A waterfall puts every request side by side.
Rows, phases, and colors
Now zoom in on one row of a bigger waterfall. The course loaded its own home page, completefrontend.com, in Chrome. It went over the real internet with an empty cache, so nothing was kept from an earlier visit. The first row is the page itself. Its bar is 240 ms long. What was it doing all that time?
Fetching one file is a small trip of its own, with stops along the way:
You need to collect a parcel from a shop you've never visited. First you look up its address. Then you drive there and show your ID at the gate. At the counter, you wait while they find your parcel. Then you carry the boxes out to the car.
- In real life: Looking up the shop's address
- In JavaScript: DNS lookup: finding the server's IP address
- In real life: Driving there and showing ID at the gate
- In JavaScript: Initial connection: TCP, then TLS
- In real life: Waiting at the counter while they find it
- In JavaScript: Waiting for the first byte
- In real life: Carrying the boxes to the car
- In JavaScript: Content download
DevTools gives each stop a name. Its Network features reference lists them in the order they happen. These are the phases of a request:
- Queueing: the request waits in line before it starts. One reason is that requests with a higher priority go first. Another is a limit. Over HTTP/1.1, the browser keeps at most six connections open to one origin. An origin is the scheme, host and port together, like
https://completefrontend.com. - Stalled: the request waits again after the connection starts, for the same reasons.
- DNS Lookup: the browser turns the host name into an IP address, the number computers use to find each other.
- Initial connection: the browser opens a connection to the server. This includes TCP, which opens the line, and TLS, which makes it private.
- Request sent: the request goes out.
- Waiting (TTFB): the browser waits for the first byte of the answer. TTFB is short for time to first byte. In DevTools' words, it includes "1 round trip of latency and the time the server took to prepare the response."
- Content Download: the rest of the bytes arrive.
Here is the page's row, phase by phase. First, the drive to the shop: setting up the connection.
// The home page's first row: the page itself. Times are in ms after the visit started.// Each pair is when one phase started and when it ended.const page = { dns: [0.8, 29.3], connect: [29.4, 78.6], tls: [51.6, 78.6] }; console.log(`DNS lookup: ${(page.dns[1] - page.dns[0]).toFixed(1)} ms`);console.log(`Initial connection: ${(page.connect[1] - page.connect[0]).toFixed(1)} ms`);console.log(`TLS, inside the connection: ${(page.tls[1] - page.tls[0]).toFixed(1)} ms`);- Line 3 holds three recorded pairs of times. Each pair is when one phase started and when it ended.
- Line 5 subtracts the DNS lookup's start from its end. It prints
DNS lookup: 28.5 ms. - Line 6 does the same for the whole connection. It prints
Initial connection: 49.2 ms. - Line 7 measures TLS. It prints
TLS, inside the connection: 27.0 ms. TLS started at 51.6 ms, inside the connection's time. So it's part of the 49.2 ms, not added on top.
So about 78 ms went on finding the server and opening a line to it. No request had gone out yet. Every new server costs this trip again, and that will matter later in this lesson.
Next, the counter and the boxes: the wait, and the download.
// The same row, once the connection was ready. Times are in ms.const page = { sent: 78.7, firstByte: 182.8, end: 240.3 }; console.log(`Waiting for the first byte: ${(page.firstByte - page.sent).toFixed(1)} ms`);console.log(`Content download: ${(page.end - page.firstByte).toFixed(1)} ms`);- Line 2 holds three recorded times: when the request went out, when the first byte came back, and when the last byte arrived.
- Line 4 prints
Waiting for the first byte: 104.1 ms. - Line 5 prints
Content download: 57.5 ms.
Now the 240 ms bar makes sense. The page is about 132 KB over the network, and downloading it took only 57.5 ms. The biggest part, 104.1 ms, went on waiting at the counter: one round trip to the server, plus the server's own work. So a long bar doesn't always mean a big file. Read its parts before you decide what to fix.
Here is the same row as a picture, with the next row under it, one of the site's stylesheets:
Two tools, two sets of colors
Each tool colors the phases its own way. DevTools gives every phase its own color. WebPageTest, a tool that loads your page in a test browser and draws its waterfall, works differently. It colors most of the bar by the type of file: blue for HTML, purple for pictures, and so on.
Each type comes in a light shade and a dark one. Pat Meenan explains the two shades in Matt Hobbs's guide. The light one is "the time from when the browser sends the request out on the wire until the first byte comes back." The dark one is "the period where the resource is actually downloading." In other words, light is the wait at the counter, and dark is carrying the boxes.
Read this table across: each row is one part of the chart, and how each tool draws it.
| Part of the chart | In DevTools' Network panel | In WebPageTest |
|---|---|---|
| A bar | Each phase has its own color: queueing, DNS, connecting, TLS, waiting and downloading. | DNS, connecting and TLS have their own colors. The rest of the bar is colored by the file's type: light while it waits, dark while it downloads. |
| The vertical lines | DOMContentLoaded is a blue line, and load is a red one. | Start render, first contentful paint, LCP, DOMContentLoaded, load and more each have a line in their own color. |
| Under the bars | Nothing. The main thread's work is in the Performance panel instead. | Four rows: the CPU, the bandwidth coming in, the main thread, and long tasks. |
Reading rows from inside the page
DevTools and WebPageTest show loads you run yourself. A page can also read its own rows during real visits, with the Resource Timing API. It gives one entry per file the page loaded, with the same start and end times. You can run this in the console of any page:
// Run in the page: each file's phases, as the Resource Timing API reports them.for (const entry of performance.getEntriesByType("resource")) { const dns = entry.domainLookupEnd - entry.domainLookupStart; const connect = entry.connectEnd - entry.connectStart; const waiting = entry.responseStart - entry.requestStart; const download = entry.responseEnd - entry.responseStart; console.log(entry.name, { dns, connect, waiting, download });}- Line 2 asks for every file's entry with
performance.getEntriesByType("resource"), and loops over them. - Lines 3 to 6 subtract the same pairs of times as before: the DNS lookup, the connection, the wait and the download.
- Line 7 prints the entry's
name, which is the file's address, and its four phases.
There's one catch. For a file from another origin, many of these times read 0. The browser hides them, unless that file's server allows it with a Timing-Allow-Origin header.
Initiators and request chains
Back to the tea page for a moment. Why did menu.json wait until 316.3 ms? Because nothing asked for it sooner. Every row in a waterfall was asked for by something: the page, a stylesheet, a script. That something is the row's initiator. It's like the "ordered by" line on a delivery slip.
DevTools shows it in the Initiator column. Its reference lists five kinds:
- Parser: Chrome's HTML parser, the part of the browser that reads the HTML. It found the file's name in a tag, such as
<link>or<img>. In the recordings, a font that a stylesheet asked for showed Parser too, with the stylesheet named as its initiator. - Script: JavaScript asked for the file. DevTools links to the line of code.
- Redirect: the server sent the browser on to a new address.
- Early Hints: an early answer from the server, with the status code
103, named the file. - Other: anything else, such as typing an address or clicking a link.
A file can only be asked for once the browser knows its name. For a file the HTML names, that's as soon as that part of the HTML arrives. For a file a script asks for, the script has to arrive and run first. And sometimes that file names another file, which names another:
You collect a parcel. Inside is a note: "Your second parcel is at the shop on Hill Road." You couldn't have gone to Hill Road earlier, because you didn't know about it. And if that parcel holds another note, you make a third trip.
- In real life: The first parcel
- In JavaScript: The page, or a file it asked for
- In real life: The note inside
- In JavaScript: A tag or a line of code that names the next file
- In real life: The next trip, only once you've read the note
- In JavaScript: The next request, which starts only after the one before it
- In real life: Trip after trip, one at a time
- In JavaScript: A request chain: a staircase of rows
When initiators wait for initiators like this, you get a request chain. Here is a short one from the home page. The code walks back from a font to the file that asked for it, and so on, until it reaches the page:
// Who asked for what, in three rows of the home page's waterfall.const askedBy = { "e6fbcfb8.css": "completefrontend.com/", "inter-latin-wght-normal.woff2": "e6fbcfb8.css",}; const chain = ["inter-latin-wght-normal.woff2"];while (askedBy[chain[0]]) chain.unshift(askedBy[chain[0]]);console.log(chain.join(" → "));- Lines 2 to 5 record two initiators. The page asked for the stylesheet, and the stylesheet asked for the font. The stylesheet's real name is a long code, so the course shortens it to its first eight characters.
- Line 7 starts a list with the font's name.
- Line 8 looks up who asked for the first name in the list, and puts that name in front. It repeats until it reaches a name nobody asked for: the page.
- Line 9 prints the list:
completefrontend.com/ → e6fbcfb8.css → inter-latin-wght-normal.woff2.
That's why a font can start late. The browser can't see the font in the HTML. It learns about it only from the stylesheet. Here the stylesheet was done at 240.9 ms, and the font was asked for at 254.5 ms.
A longer chain shows the shape better. These four rows come from the same waterfall:
// Four rows of the home page's waterfall. Each was asked for by the row above it.const rows = [ ["layout.js", 188.3, 309.7], ["z18swiqmi2", 403.8, 917.3], ["clarity.js", 918.2, 1323.2], ["collect", 1434.3, 2496.7],]; for (const [name, asked, done] of rows) console.log(`${name}: asked for at ${asked} ms, done at ${done} ms`);- Lines 3 to 6 hold four rows: each one's name, when it was asked for, and when its last byte arrived.
- Line 9 prints one sentence per row. The first two are
layout.js: asked for at 188.3 ms, done at 309.7 msandz18swiqmi2: asked for at 403.8 ms, done at 917.3 ms. The last two areclarity.js: asked for at 918.2 ms, done at 1323.2 msandcollect: asked for at 1434.3 ms, done at 2496.7 ms.
Compare each row with the one above it. clarity.js was asked for 0.9 ms after z18swiqmi2 was done. collect was asked for 111.1 ms after clarity.js was done, once that script had run. Each trip waited for the one before.
The last three rows belong to Microsoft Clarity, an analytics script the site uses. z18swiqmi2 is Clarity's tag, a small script that loads clarity.js. Clarity's last row finished at 2496.7 ms, long after the page's load event at 366.9 ms. On a waterfall, a chain like this looks like a staircase:
Every step in a chain is one more trip, and each trip waits for the one before. That's why chains are slow. The table near the end of this lesson shows how to shorten one.
When the initiator says Other
Chrome doesn't always name the file that asked. One of the tea pages the course built adds its picture with a script, after a fetch for menu.json comes back. Chrome gave the picture's row the initiator Other. But the timing still tells: the picture was asked for 0.5 ms after menu.json's last byte arrived.
So when you see Other, line the row up with the rows above it. A row that starts the moment another one ends was probably waiting for it.
Now explore the real waterfall yourself. Click any row. The panel shows its phases, its connection and its initiator. The names of the rows that led to it turn green, and the names of the rows it asked for turn red. You can color the bars by phase, as DevTools does, or by file type, as WebPageTest does.
- Queueing
- DNS lookup
- Connecting
- TLS
- Waiting for the first byte
- Content download
- Parsing
- Script
- Style and layout
- Paint
- Other
- First contentful paint
- Largest contentful paint
- DOMContentLoaded
- load
completefrontend.com155, new, h2HighestOther131,989240 msRow 1 is completefrontend.com/, from completefrontend.com. Its phases: DNS lookup 28.5 ms, connecting 22.2 ms, TLS 27.0 ms, waiting 104.1 ms, downloading 57.5 ms. It opened a new connection, number 155 (h2), which carried 12 requests. The visit itself asked for it. It asked for 24 rows itself.
DevTools can highlight a row's initiators too. One of the exercises asks how.
Connection reuse and new connections
Look back at the picture of the page's row. The stylesheet under it had no DNS lookup and no connection part. Its bar started at the wait. Why?
Think back to collecting a parcel. The first trip to a shop costs the address, the drive and the ID check. For a second parcel from the same shop, you're already standing at the counter. Browsers work the same way.
A connection is an open line between the browser and one server. Once it's open, the browser keeps it, and sends the next requests to that server along it. So only the first request on a connection pays for the DNS lookup, connecting and TLS. The rest start at the wait.
Here are the connections the home page used, and how many requests each one carried:
// The home page's connections: the host each one went to, and how many requests it carried.const connections = [ ["completefrontend.com", 12], ["completefrontend.com", 20], ["www.clarity.ms", 1], ["scripts.clarity.ms", 1], ["c.clarity.ms", 1], ["s.clarity.ms", 1],]; const requests = connections.reduce((sum, [host, count]) => sum + count, 0);console.log(`${requests} requests over ${connections.length} connections`);- Lines 2 to 9 list the six connections, in the order they first appear. Each has the host it went to, and how many requests it carried.
- Line 11 adds up the requests.
- Line 12 prints
36 requests over 6 connections.
So 31 of the 36 requests went down a connection that was already open. Lines 3 and 4 are both the site's own. The first, over HTTP/2, carried the page and its 11 stylesheets. Then the scripts and fonts moved to a second one, over HTTP/3. The page's answer had carried an Alt-Svc header, which tells the browser the server also speaks HTTP/3. Lesson 4.6, HTTP/3 and QUIC, explains it.
Now the connections that had to be opened. Four of them show a setup in their rows:
// The connections whose rows show a setup: DNS lookup and initial connection, in ms.const setups = [ ["completefrontend.com", 28.5, 49.2], ["www.clarity.ms", 237.7, 202.3], ["scripts.clarity.ms", 134.3, 178.7], ["s.clarity.ms", 139.3, 415.1],]; for (const [host, dns, connect] of setups) console.log(`${host}: ${(dns + connect).toFixed(1)} ms to connect`);- Lines 2 to 7 list four connections. Each has its host, its DNS lookup and its initial connection, in ms.
- Line 9 adds the two times for each connection, and prints one sentence per host.
- It prints
completefrontend.com: 77.7 ms to connectandwww.clarity.ms: 440.0 ms to connect. Then it printsscripts.clarity.ms: 313.0 ms to connectands.clarity.ms: 554.4 ms to connect.
The site's own connection took 77.7 ms to open. Each of Clarity's took four to seven times as long. And they didn't open side by side. They opened one after another, because each Clarity file was asked for by the one before: the chain from the last section. Together, Clarity spent 1307.4 ms just opening connections.
That's why new connections matter. Every new host costs a setup before its first request can go out. On this page, Clarity's hosts paid it three times. Fewer hosts means fewer first trips to new shops.
DevTools can show which rows shared a connection, too. One of the exercises asks how.
Gaps that mean the main thread is busy
Sometimes a waterfall has a stretch where no bar is moving. Nothing is downloading. Is the browser resting? Not always.
Every page has a main thread: one worker that runs the page's JavaScript, works out its layout, and paints it. MDN explains that the browser "uses a single thread to run all the JavaScript in your page, as well as to perform layout". So it does one thing at a time. While a script keeps it busy, the code that would ask for the next file can't run either.
At the depot, every van is parked and ready. But no van leaves without a delivery slip, and one person writes all the slips. Today she's stuck on a long phone call. The vans sit in the yard until she hangs up and writes the next slip.
- In real life: The vans parked in the yard
- In JavaScript: The network, with nothing downloading
- In real life: The one person who writes the delivery slips
- In JavaScript: The main thread
- In real life: Her long phone call
- In JavaScript: A long task: one piece of work that runs for a long time
- In real life: The next slip, written after the call
- In JavaScript:
fetch("menu.json"), which runs only when the long task is done
The course built a tea page to show this. Its app.js starts with a loop that keeps the main thread busy for 600 ms. Only then does it ask for menu.json. First, find the quiet stretch in its waterfall:
// The busy tea page's requests: name, when Chrome started on it, and its last byte, in ms.const requests = [ ["index.html", 0.8, 152.9], ["styles.css", 154.3, 307.2], ["app.js", 154.5, 307.6], ["tea.svg", 154.7, 307.5], ["menu.json", 915.7, 1069.3],]; const quietFrom = Math.max(...requests.slice(0, 4).map((request) => request[2]));console.log(`nothing downloading from ${quietFrom} to ${requests[4][1]} ms`);- Lines 3 to 7 hold the five requests: each one's name, when Chrome started on it, and when its last byte arrived.
- Line 10 finds when the first four were all done.
requests.slice(0, 4)takes the first four rows,maptakes each one's end time, andMath.maxpicks the latest. - Line 11 prints that time, and when the fifth request started:
nothing downloading from 307.6 to 915.7 ms.
For 608.1 ms, nothing came in. Was the browser resting? Look at what the main thread was doing at the same time.
From Chrome's trace, its detailed log of the load, the course picked out each long task. A long task is one piece of work that keeps the main thread busy for 50 ms or more without a break. The busy page had one:
// The busy tea page's one long task, from Chrome's trace, in ms.const longTask = { start: 314.9, duration: 600.6 }; const end = longTask.start + longTask.duration;console.log(`main thread busy from ${longTask.start} to ${end.toFixed(1)} ms`);- Line 2 holds the long task Chrome recorded: when it started, and how long it ran.
- Line 4 adds the two to find when it ended.
- Line 5 prints
main thread busy from 314.9 to 915.5 ms.
Put the two side by side. The quiet stretch began at 307.6 ms, and the long task 7.3 ms later. The task ended at 915.5 ms, and menu.json started 0.2 ms after that. The vans were parked the whole time, ready to go. The dispatcher was busy.
That changes what you fix. A faster server or a faster network wouldn't shorten this gap. The fix is in the JavaScript: run less of it, run it later, or split it into smaller pieces. Pat Meenan, quoted in Matt Hobbs's guide, calls "gaps in the waterfall where the CPU is pegged at 100%" a sign of JavaScript that needs optimizing. Pegged at 100% means stuck at full use.
A gap with an empty main-thread row is different. Then the browser really is idle. Something told it to wait, such as a timer in the page's code.
The CPU and bandwidth rows
How do you know the main thread was busy during a gap? You read the rows under the bars. WebPageTest draws four of them, and Matt Hobbs's guide describes each one:
- CPU Utilization: how hard the browser's process is working, from 0 to 100%.
- Bandwidth In: when bytes are arriving. Pat Meenan's advice: "Don't pay too much attention to the absolute scale." Read its shape, not its numbers.
- Browser Main Thread: what the main thread is doing, by color. Orange is script, purple is layout, green is painting, blue is parsing HTML, and gray is everything else.
- Long Tasks: red blocks where a long task kept the main thread busy.
Think back to the depot. The main-thread row is the dispatcher's diary: for every moment, it says what she was doing. The bandwidth row shows how busy the road was.
The course's recordings keep the same diary, taken from Chrome's trace. They cut the load into 50 ms slices, called buckets. Then they add up how long each kind of work took in each one. Here is the home page's main thread, from 200 to 400 ms after the visit started:
// The home page's main thread, 200 to 400 ms after the visit started.// One bucket per 50 ms. Each number is the ms spent on one kind of work.const buckets = [ { parsing: 5.3, script: 2.8, layout: 0.2, paint: 0, other: 6 }, { parsing: 0, script: 0, layout: 50, paint: 0, other: 0 }, { parsing: 0, script: 13.6, layout: 17.4, paint: 13.1, other: 5.3 }, { parsing: 0, script: 46.4, layout: 0.9, paint: 1, other: 1.7 },]; buckets.forEach((bucket, index) => { const busy = Object.values(bucket).reduce((sum, ms) => sum + ms, 0); const [mostly] = Object.entries(bucket).sort((a, b) => b[1] - a[1])[0]; console.log(`${200 + index * 50} ms: ${Math.round((busy / 50) * 100)}% busy, mostly ${mostly}`);});- Lines 4 to 7 are four recorded buckets. Line 5, for example, holds 50 ms of layout and nothing else.
otheris work that isn't parsing, script, layout or paint. - Line 11 adds up all the work in one bucket.
- Line 12 sorts the kinds of work from most to least, and takes the name of the first.
- Line 13 prints the bucket's start, how much of its 50 ms was busy, and its biggest kind of work.
- It prints
200 ms: 29% busy, mostly other,250 ms: 100% busy, mostly layout,300 ms: 99% busy, mostly layoutand350 ms: 100% busy, mostly script.
Layout is working out where every element goes and how big it is. From 250 to 400 ms, the main thread had almost no time to spare. The trace shows one long task in that stretch: 63.2 ms, starting at 249.8 ms. The first text or image appeared on screen at 392 ms, after the busy stretch.
The bandwidth row tells the other half of the story: how full the line is. To show it, the course made a tea page whose picture hides about 600 KB of extra data, the kind a drawing program saves for itself. The browser never uses it. The course's server sent every byte through one line of 5 Mbps, 5 million bits per second. A byte is 8 bits.
// The big tea page's picture: its size in bytes, and when its first and last bytes came, in ms.const tea = { bytes: 616730, firstByte: 309.5, end: 1294.8 }; const ms = tea.end - tea.firstByte;console.log(`downloading for ${ms.toFixed(1)} ms`);console.log(`at ${((tea.bytes * 8) / ms / 1000).toFixed(2)} Mbps`);- Line 2 holds the recorded picture: its size in bytes, and when its first and last bytes arrived.
- Line 4 works out how long the download took. Line 5 prints
downloading for 985.3 ms. - Line 6 turns the bytes into bits, and divides by the time. Bits per millisecond are thousands of bits per second, so it divides by 1,000 once more to get millions. It prints
at 5.01 Mbps.
That's the whole line. For almost a second, one picture filled it, and the bandwidth row sat flat at the top. The fix here isn't a faster server. It's fewer bytes.
Spotting the five most common problems
You can now read every part of a waterfall: the phases, the initiators, the connections, the gaps, and the rows under the bars. Each of the five problems below leaves its own shape on them. Finding it works like reading a bill:
Your phone bill is twice what it usually is. Staring at the total won't tell you why. So you read it line by line, next to last month's bill, and look for the one line that grew. That line tells you what to change.
- In real life: The total at the bottom
- In JavaScript: How long the page took to load
- In real life: One line on the bill
- In JavaScript: One part of the waterfall: a phase, a gap, a row under the bars
- In real life: The line that grew since last month
- In JavaScript: The shape that gives the problem away
- In real life: Last month's bill
- In JavaScript: A healthy waterfall to compare with
Read this table across: each row is one problem, the shape it leaves, and how to fix it.
| The problem | What it looks like | How to fix it |
|---|---|---|
| A slow server | The page's own row is mostly waiting, far longer than one round trip. The CPU and bandwidth rows sit idle under it. | Make the server answer sooner: keep ready-made pages, speed up slow database queries, or serve from a CDN. |
| A request chain | A staircase. Each row starts just as the row above it ends. | Name the files in the HTML, or preload them, so the browser finds them all at once. |
| New connections | DNS, connecting and TLS at the start of many rows, often for other sites. | Use fewer hosts, serve the files from your own site, or preconnect to the hosts you need. |
| Too many bytes | One long dark bar, with the bandwidth row full under it. | Send fewer bytes: compress files, resize pictures, and remove what nobody uses. |
| A busy main thread | A gap where nothing downloads, while the main-thread row is full. | Run less JavaScript, run it later, or split it into smaller pieces. |
Matt Hobbs's guide shows these shapes on real waterfalls. In one, the server took about 3 seconds to build its HTML, while the bandwidth and CPU rows sat almost idle. In another, a script from another domain spent 220 ms on DNS, connecting and TLS before the browser could ask for it.
A slow server, or a slow network?
The first shape needs care. A long wait for the first byte can mean the server is slow. It can also mean the server is far away. How do you tell them apart? Compare the wait with one round trip. Here is the page's own row on two tea pages, over the same network:
// The page's own row on two tea pages, over the same 150 ms round trip. Times are in ms.const healthy = { sent: 1.6, firstByte: 152.7 };const slowServer = { sent: 0.9, firstByte: 1151.3 }; console.log(`healthy page: waited ${(healthy.firstByte - healthy.sent).toFixed(1)} ms`);console.log(`slow server: waited ${(slowServer.firstByte - slowServer.sent).toFixed(1)} ms`);- Lines 2 and 3 hold the two recorded rows: when the request went out, and when the first byte came back.
- Line 5 prints
healthy page: waited 151.1 ms. - Line 6 prints
slow server: waited 1150.4 ms.
One round trip here is 150 ms. The healthy page waited one round trip, so its server answered almost at once. The other page waited about a second more, over the same network. That extra second was the server.
On a real site, you don't know the round trip in advance. Matt Hobbs's trick is to look at the connection time. Opening a connection takes at least one round trip, as TCP connections and slow start showed. So the connection time shows roughly how far away the server is. A wait many times longer than that is the server's own work.
Now try it. Below are five recorded waterfalls, each showing one of the five problems. Four are tea pages, served from the course's computer over the same imitated network: a 150 ms round trip and one 5 Mbps line. The fifth is the home page from earlier. Pick a waterfall, then pick the problem you think it shows. If you get stuck, compare it with the healthy page.
- Queueing
- DNS lookup
- Connecting
- TLS
- Waiting for the first byte
- Content download
- Parsing
- Script
- Style and layout
- Paint
- Other
- First contentful paint
- Largest contentful paint
- DOMContentLoaded
- load
62,801770 ms776 ms0.3 msnoneWaterfall 1: 6 requests, the last byte at 770 ms. Which problem does it show?
Which problem is it?
For each shape below, ask one question: what was the page waiting for? It could be the server or the network, the browser finding its files, or the main thread. Sort each shape into its pile:
- The page's own row is mostly waiting for its first byte
- DNS, connecting and TLS at the start of another site's rows
- One long dark bar, with the bandwidth row full under it
- A staircase: each row starts as the row above it ends
- A font asked for only after its stylesheet arrived
- A picture asked for after a script and a fetch
- A gap where nothing downloads, with the main-thread row full
- A long task just before a fetch finally starts
Sort each shape by what the page was waiting for.
Common mix-ups
- "A long bar means a big file." No. The home page's row was 240 ms long. About 78 ms of it was setting up the connection, and 104 ms was waiting for the first byte. Downloading its 132 KB took only 58 ms.
- "The first row is all server time." No. The DNS lookup, connecting and TLS come first. Only the wait belongs to the server, and even the wait includes one round trip.
- "Every request opens a new connection." No. On the home page, 31 of the 36 requests went down a connection that was already open.
- "A gap means the browser is resting." No. On the busy tea page, the main thread was busy for the whole gap.
- "The Initiator column always names the file that asked." No. A picture that a script added showed Other. Its timing gave it away.
- "The bandwidth row gives exact speeds." No. It shows when bytes arrive. Its scale is only a rough guide.
Practice exercises
Answer first, then use the hints.
The server now takes 300 ms longer to answer. What does the first line print?
// The same row, if the server took 300 ms longer to answer. Times are in ms.
const page = { sent: 78.7, firstByte: 482.8, end: 540.3 };
console.log(`Waiting for the first byte: ${(page.firstByte - page.sent).toFixed(1)} ms`);
console.log(`Content download: ${(page.end - page.firstByte).toFixed(1)} ms`);Waiting for the first byte: 404.1 ms. The download stays 57.5 ms, because the first and last bytes both moved by 300 ms. Only the wait grew.
Suppose the page asked for clarity.js itself, with no tag in between. What does this print?
// What if the page asked for clarity.js itself?
const askedBy = {
"layout.js": "completefrontend.com/",
"z18swiqmi2": "layout.js",
"clarity.js": "completefrontend.com/",
"collect": "clarity.js",
};
const chain = ["collect"];
while (askedBy[chain[0]]) chain.unshift(askedBy[chain[0]]);
console.log(chain.join(" → "));completefrontend.com/ → clarity.js → collect. The chain has two steps instead of four, so collect could start two trips sooner.
In the real waterfall, row 29 is a script that was asked for at about 401 ms. Which file asked for it?
webpack.js, row 13. The page asked for it. Once it ran, it asked for rows 29 to 32, at about 401 ms.
In the spot-the-problem playground, which waterfall shows a busy main thread?
Waterfall 2. Nothing downloads from 308 to 916 ms, while a 601 ms long task runs.
In DevTools' Network panel, which key do you hold while hovering a request to see its initiators and dependencies?
Shift. DevTools colors the request's initiators green, and the requests it started red.
Which column of DevTools' Network panel shows whether two requests shared a connection?
Connection ID. Rows with the same ID went down the same connection.
Check your understanding
For each question, ask which part of the waterfall tells you: a phase, an initiator, a connection, a gap, or a row under the bars.
Question 1 of 7What does the first line of the waiting example print?
Read the code, then predict// The same row, once the connection was ready. Times are in ms. const page = { sent: 78.7, firstByte: 182.8, end: 240.3 }; console.log(`Waiting for the first byte: ${(page.firstByte - page.sent).toFixed(1)} ms`); console.log(`Content download: ${(page.end - page.firstByte).toFixed(1)} ms`);Choose an answer to see the explanation.
Question 2 of 7In WebPageTest, what do a bar's light and dark shades mean?
Choose an answer to see the explanation.
Question 3 of 7What does this walk back up Clarity's chain print?
Read the code, then predict// Who asked for what, in the rows that led to Clarity's last request. const askedBy = { "layout.js": "completefrontend.com/", "z18swiqmi2": "layout.js", "clarity.js": "z18swiqmi2", "collect": "clarity.js", }; const chain = ["collect"]; while (askedBy[chain[0]]) chain.unshift(askedBy[chain[0]]); console.log(chain.join(" → "));Choose an answer to see the explanation.
Question 4 of 7In the recorded site, why do the stylesheets' rows have no DNS, connection or TLS time?
Choose an answer to see the explanation.
Question 5 of 7A waterfall has a long gap where nothing downloads, and the main-thread row under it is full. What's the likely cause?
Choose an answer to see the explanation.
Question 6 of 7In these recordings, what was the initiator of a picture that a script added to the page?
Choose an answer to see the explanation.
Question 7 of 7How can you tell a slow server from a slow network on the page's row?
Choose an answer to see the explanation.
Key takeaways
- A waterfall is the timetable of a page load: one row per request, with time going right.
- A row's parts are its phases: queueing, the DNS lookup, connecting with TLS, waiting and downloading. A long bar isn't always a big file.
- Only the first request on a connection pays for its setup. The rest start at the wait.
- A row's initiator is what asked for it. When initiators wait for initiators, the rows make a staircase.
- A gap with the main-thread row full is JavaScript, not the network.
- The rows under the bars show when the CPU, the main thread and the line were busy.
- Five shapes give away five problems: a slow server, a chain, new connections, too many bytes, and a busy main thread.
Remember the one-liner.
Read the bars for what each request waited on, and the gaps for what the page waited on.
Sources
The facts on this page come from the course's own recordings, and from these docs and articles:
- Network features reference, Chrome DevTools.
- How to read a WebPageTest Waterfall View chart, Matt Hobbs.
- Resource timing, MDN.
- PerformanceResourceTiming, MDN.
- Main thread, MDN.
- PerformanceLongTaskTiming, MDN.
Coming next: Processes and threads. You've seen one busy main thread hold up a whole page. Next, you'll meet the browser's processes, and see why almost everything you write runs on that one thread.