completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

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.

By the end, you can
  • 01
    Read a rowName each part of a request's bar, from queueing to download, and say what each part was waiting for.
  • 02
    Follow the storyTrace who asked for each file, see which requests shared a connection, and read the CPU and bandwidth rows under the bars.
  • 03
    Spot 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:

Five files, two times eachPop out in the code editor (opens in a new tab)JavaScript
// 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 is menu.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:

The Mint tea page's waterfall, one row per file: index.html from 0 ms to 153 ms; styles.css from 156 ms to 309 ms; app.js from 157 ms to 309 ms; tea.svg from 157 ms to 309 ms; menu.json from 316 ms to 470 ms. The first text or image was painted at 328 ms.index.html153 msstyles.css309 msapp.js309 mstea.svg309 msmenu.json470 mswaits for app.jsfirst paint, 328 ms0 ms
One row per file, with time going right. Three files left together once the page had arrived. menu.json waited for app.js.

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.
Real-life analogyA delivery schedule

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
The main idea

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:

Real-life analogyCollecting a parcel

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 page's row: setting up the connectionPop out in the code editor (opens in a new tab)JavaScript
// 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 page's row: waiting and downloadingPop out in the code editor (opens in a new tab)JavaScript
// 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 rows of the real site's waterfall. The page: DNS 28.5 ms, connecting 22.2 ms, TLS 27.0 ms, waiting for the first byte 104.1 ms, downloading 57.5 ms. A stylesheet on the same connection: no setup, waiting 49.1 ms, downloading 0.2 ms.setting up the connection: 77.7 msDNS28.5 msconnect22.2 msTLS27.0 mswaiting104.1 msdownload57.5 msthe page72c549a5.cssreused: no setupwaitingdownload49.1 ms0.2 ms
The page's row pays for the connection first. The stylesheet under it uses the same connection, so its bar starts at the wait.

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.

Two tools, two color schemes
Part of the chartIn DevTools' Network panelIn WebPageTest
A barEach 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 linesDOMContentLoaded 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 barsNothing. 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:

Rows from inside the pageJavaScript
// 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:

Real-life analogyA parcel with a note inside

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:

Walking back from a font to the pagePop out in the code editor (opens in a new tab)JavaScript
// 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, each asked for by the one abovePop out in the code editor (opens in a new tab)JavaScript
// 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 ms and z18swiqmi2: asked for at 403.8 ms, done at 917.3 ms. The last two are clarity.js: asked for at 918.2 ms, done at 1323.2 ms and collect: 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:

A request chain on the real site: the page, from 0 ms to 240 ms; then layout.js, from 188 ms to 310 ms; then z18swiqmi2, from 404 ms to 917 ms; then clarity.js, from 918 ms to 1,323 ms; then collect, from 1,434 ms to 2,497 ms. Each one was started by the one before it.the page0 ms to 240 mslayout.js188 ms to 310 msz18swiqmi2404 ms to 917 msclarity.js918 ms to 1,323 mscollect1,434 ms to 2,497 ms
Each row waits for the row above it. Together they make a staircase down and to the right.

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.

Playground: a real waterfall
https://completefrontend.com/0 to 2.5 s
  • 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
Row 1: completefrontend.com/recorded
Hostcompletefrontend.com
Connection155, new, h2
PriorityHighest
InitiatorOther
Bytes131,989
Done at240 ms
Try it yourself
The row

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

The course's home page, loaded in Chrome over the internet with an empty cache: 36 requests. Its times change from load to load, with the network. Click a row. The names of the rows that led to it turn green, and the names of the rows it asked for turn red. The main-thread row shows each 50 ms by kind of work. The bandwidth row is drawn against its own highest point.

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:

Six connections for 36 requestsPop out in the code editor (opens in a new tab)JavaScript
// 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:

What each new connection costPop out in the code editor (opens in a new tab)JavaScript
// 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 connect and www.clarity.ms: 440.0 ms to connect. Then it prints scripts.clarity.ms: 313.0 ms to connect and s.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.

Real-life analogyParked vans and a busy dispatcher

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 quietest stretch on the busy pagePop out in the code editor (opens in a new tab)JavaScript
// 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, map takes each one's end time, and Math.max picks 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 page's long taskPop out in the code editor (opens in a new tab)JavaScript
// 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:

What the main thread did, 50 ms at a timePop out in the code editor (opens in a new tab)JavaScript
// 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. other is 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 layout and 350 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.

A line, fullPop out in the code editor (opens in a new tab)JavaScript
// 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:

Real-life analogyReading a bill line by line

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.

Five shapes, five problems
The problemWhat it looks likeHow to fix it
A slow serverThe 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 chainA 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 connectionsDNS, 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 bytesOne 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 threadA 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 same network, two serversPop out in the code editor (opens in a new tab)JavaScript
// 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.

Playground: spot the problem
Waterfall 10 to 1.5 s
  • 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
The evidencerecorded
Requests6
Bytes2,801
Last byte770 ms
LCP776 ms
Longest connection setup0.3 ms
Long tasksnone
Try it yourself
The diagnosis

Waterfall 1: 6 requests, the last byte at 770 ms. Which problem does it show?

Four of the waterfalls are tea pages, served from the course's own computer. It held every answer back 150 ms, one round trip, and sent every byte through one 5 Mbps line. Connections to it open at once. Their bandwidth row is full when that line is full. The waterfall with other sites in it is the course's home page, over the internet. Bars are colored by phase.

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:

Which problem is it?
  • 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
Try it yourself
0 of 8 correct

Sort each shape by what the page was waiting for.

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

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.

Exercise 1 · Warm-upA slower server

The server now takes 300 ms longer to answer. What does the first line print?

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

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

    Exercise 2 · Warm-upA shorter chain

    Suppose the page asked for clarity.js itself, with no tag in between. What does this print?

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

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

      Exercise 3 · PracticeWho asked for row 29?

      In the real waterfall, row 29 is a script that was asked for at about 401 ms. Which file asked for it?

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

        Exercise 4 · PracticeThe busy waterfall

        In the spot-the-problem playground, which waterfall shows a busy main thread?

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

          Exercise 5 · ChallengeInitiators in DevTools

          In DevTools' Network panel, which key do you hold while hovering a request to see its initiators and dependencies?

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

            Exercise 6 · ChallengeShared connections

            Which column of DevTools' Network panel shows whether two requests shared a connection?

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

              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.

              The waterfall quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the first line of the waiting example print?

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

              2. Question 2 of 7In WebPageTest, what do a bar's light and dark shades mean?

                Choose an answer to see the explanation.

              3. Question 3 of 7What does this walk back up Clarity's chain print?

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

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

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

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

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

              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.

              CompleteFrontend Clear concepts. Working examples.