The navigation timeline
Follow real navigations from the address bar to the first paint, phase by phase, with the Navigation Timing timestamps that measure each one.
- 01Follow a page load, job by jobFrom leaving the old page and following redirects, through DNS, TCP and TLS, to the first byte, the load event and the first paint.
- 02Read the browser's stopwatchSubtract two times in the navigation entry to see how long one job took, and tell when a zero means hidden, skipped or reused.
- 03Find where the time wentTell whether a slow first byte comes from a redirect, the connection, or the server itself.
From the address bar to the first paint
You type completefrontend.com and press Enter. About a third of a second later, words appear on the screen. It feels like one step. It isn't. In that third of a second, the browser did about ten separate jobs, one after another, like runners in a relay. Each runner has to finish before the next one can start.
The good news: the browser keeps a stopwatch running the whole time. Each time one job ends and the next begins, it writes down the time. You can read those notes from JavaScript. This lesson follows one real visit, job by job, and reads the stopwatch at every step.
Here are three of the times Chrome wrote down when the course visited completefrontend.com for the first time:
1// The first visit to completefrontend.com, as Chrome wrote it down.2// Every time is in milliseconds (ms) after the visit started.3const visit = { requestStart: 83.9, responseStart: 104.5, loadEventEnd: 309.8 };4 5console.log(`The browser asked for the page at ${visit.requestStart} ms.`);6console.log(`The first piece of the page came back at ${visit.responseStart} ms.`);7console.log(`The page finished loading at ${visit.loadEventEnd} 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, so 309.8 ms is about a third of a second.
- Line 3 holds three times Chrome recorded. The names come from the browser:
requestStart,responseStartandloadEventEnd. - Lines 5 to 7 print them:
The browser asked for the page at 83.9 ms.,The first piece of the page came back at 104.5 ms.andThe page finished loading at 309.8 ms.
Look at the first line again. The browser didn't even ask for the page until 83.9 ms had passed. What was it doing all that time? And once the first piece arrived, why did it take another 200 ms to finish? The rest of this lesson answers both questions. Here is the whole visit at once, each job as one bar:
You order a parcel. Its tracking page shows a list of scans, each with a time: "Picked up, 9:02. Left the depot, 9:40. Out for delivery, 11:15. Delivered, 11:30." The page never tells you why the parcel was slow. But the gaps tell you. If "left the depot" and "out for delivery" are hours apart, the parcel sat in a van all morning.
- In real life: Each scan of the parcel, with its time
- In JavaScript: Each time Chrome writes down, like
requestStart - In real life: The gap between two scans
- In JavaScript: How long one job took
- In real life: The whole list of scans
- In JavaScript: The navigation entry
- In real life: "Delivered, 11:30"
- In JavaScript:
loadEventEnd: the page finished loading
Loading a page is a chain of jobs. The browser writes down the time each job starts and ends. To find out how long a job took, subtract its start time from its end time. That one subtraction tells you where the time went: before the request, setting up the connection, waiting for the server, or building the page.
Where does this list of times live? Each page has one navigation entry: an object the browser fills in as the page loads. You get it with performance.getEntriesByType("navigation")[0]. Its type is called PerformanceNavigationTiming, after the standard that defines it, Navigation Timing. The jobs fall into four stages:
- Before the request: leave the old page, follow any redirects, start a service worker, and check the browser's own cache.
- The connection: find the server's address, open a line to it, and make the line private.
- The request and response: ask for the page, wait for the server, and download the HTML.
- The document: read the HTML, run scripts, load images, and paint.
The next four sections take one stage each. For the same story without a stopwatch, see From URL to pixels. For the events a page fires while it loads, see Page & resource loading.
Unload, redirect, and service worker phases
Three things can happen before the browser asks a server for anything. Most visits skip at least one of them. When a job is skipped, its times are 0.
Leaving the old page
If you were already looking at a page, the browser has to let it go. Just before it does, it fires an unload event on the old page. That's the old page's last chance to run code. The new page's navigation entry records when it happened, in unloadEventStart and unloadEventEnd.
There's a catch. You only see these times if the old page was on the same origin as the new one. An origin is the scheme, host and port together, like https://completefrontend.com. If you came from a search engine, both times are 0. The browser hides them, so one site can't learn anything about another. The course went from the home page to /web-performance in the same tab:
1// Going from the home page to /web-performance, in the same tab.2const visit = { responseStart: 30.1, unloadEventStart: 37.3 };3 4console.log(`The new page's first byte came at ${visit.responseStart} ms.`);5console.log(`The old page's unload started at ${visit.unloadEventStart} ms.`);- Line 2 holds two times recorded in the new page's entry.
- Line 4 prints
The new page's first byte came at 30.1 ms. - Line 5 prints
The old page's unload started at 37.3 ms.
Notice the order. The old page's unload ran after the new page's first byte had arrived. The browser didn't throw the old page away the moment you clicked. It kept it on screen until the new page had started arriving, so you never stare at an empty window. It's like a TV that keeps showing the old channel until the new one has a picture.
One warning about unload. Chrome's team calls it "extremely unreliable". On phones it often doesn't run at all, because tabs are put in the background and then closed. The exercises ask what to use instead.
Following a redirect
You walk to your favourite tea shop. The door is locked, and there's a sign: "We've moved two doors down." You walk on, and this time the door opens. Nobody meant to waste your time, but the walk to the first door still cost you a few minutes.
- In real life: The old door, with the sign on it
- In JavaScript: The first address, like
/web-performance/ - In real life: The sign: "We've moved two doors down"
- In JavaScript: The server's answer:
301and a new address - In real life: Walking on to the new door
- In JavaScript: The browser asking again, at the new address
A redirect is that sign. The browser asks for one address, and the server answers "it lives somewhere else now", with a status code like 301 and the new address. Then the browser asks again. Everything the first ask cost counts as the redirect: finding the server, connecting, asking, and reading the answer. Navigation Timing times it from redirectStart to redirectEnd.
Like unload, a redirect is only reported when it stayed on the same origin. If any step went through another origin, both times are 0. This site sends /web-performance/, with a slash at the end, to /web-performance, without one. The course timed it:
1// A visit to /web-performance/ (with a slash at the end).2// The server answered: "this page lives at /web-performance now".3const visit = { redirectStart: 0.3, redirectEnd: 274.9, requestStart: 276.5, responseStart: 300.1 };4 5const redirect = visit.redirectEnd - visit.redirectStart;6const realRequest = visit.responseStart - visit.requestStart;7console.log(`Following the redirect: ${redirect.toFixed(1)} ms`);8console.log(`The real request, once it left: ${realRequest.toFixed(1)} ms`);- Lines 1 and 2 say what happened: the browser asked for the address with the slash, and was sent to the one without.
- Line 3 holds four recorded times.
- Line 5 subtracts the redirect's start from its end. Line 6 does the same for the real request: from the moment it left to its first byte.
- It prints
Following the redirect: 274.6 msandThe real request, once it left: 23.6 ms.
The real request took about 24 ms. The detour took more than ten times as long. It was a whole trip of its own: finding the server, connecting, asking, and getting the sign back. This picture shows the two trips, one after the other:
Links that point straight at the final address never meet the sign, so they skip the detour.
Asking a service worker
A service worker is a small script a site can install in your browser. It sits between the page and the network. When the page asks for something, the service worker gets the first chance to answer, often from files it saved earlier. It's like a receptionist who answers some questions from their own notes, without phoning the office.
If a site has one, the browser has to start the service worker, or wake it up, before the request. workerStart marks that moment. On sites without a service worker, it's 0. The course timed a page on its own computer whose service worker answered it:
1// A page whose service worker answered it, on the course's own computer.2const visit = { workerStart: 1.1, fetchStart: 1.2, responseStart: 3.6 };3 4console.log(`The service worker started its work at ${visit.workerStart} ms.`);5console.log(`The fetch began at ${visit.fetchStart} ms.`);6console.log(`The first byte came at ${visit.responseStart} ms.`);- Line 2 holds three recorded times.
- It prints
The service worker started its work at 1.1 ms.,The fetch began at 1.2 ms.andThe first byte came at 3.6 ms.
The service worker started just before the fetch and answered in a few milliseconds, with no network at all. Starting a worker that isn't running yet takes time of its own, and that time also comes before the first byte.
Here are all four recorded visits side by side. Pick one to see which jobs it did, and which it skipped:
105 ms259 ms310 ms332 msA first visit to completefrontend.com. There was no old page, no redirect and no service worker. Nothing was open yet, so the browser looked up the address (25 ms), opened a line and made it private before the request could leave at 84 ms. The server's own part took 21 ms.
Cache lookup, DNS, connect, and TLS
Now the browser starts fetching for real. That moment is called fetchStart.
The first thing it does costs almost nothing. It looks in its own HTTP cache, a store of files it kept from earlier visits. If a fresh copy of the page is there, it can skip the network completely. The look ends at domainLookupStart.
If the page isn't in the cache, the browser has to reach the server. On a first visit, that takes three jobs. They're easier to picture as a phone call:
You want to order from a shop you've never called. First you look up its number. Then you dial, it rings, and someone says "Hello?". Before you read out your card number, you both agree on a private code, so nobody listening can follow. Only then do you place your order.
- In real life: Looking up the shop's number
- In JavaScript: DNS: turning completefrontend.com into an IP address
- In real life: Dialling until someone says "Hello?"
- In JavaScript: TCP: opening a connection to the server
- In real life: Agreeing on a private code
- In JavaScript: TLS: setting up encryption, the S in HTTPS
- In real life: Placing your order
- In JavaScript: The request, at
requestStart
- DNS turns a name like completefrontend.com into an IP address, the number computers use to find each other. It runs from
domainLookupStarttodomainLookupEnd. DNS lookups covers it in depth. - TCP opens a connection. The browser and the server greet each other before any page data flows. It runs from
connectStarttosecureConnectionStart. See TCP connections and slow start. - TLS sets up encryption, so nobody in between can read the page. It runs from
secureConnectionStarttoconnectEnd. See TLS without the wait.
Here is the first visit again, with the cache check and all three jobs:
1// The first visit to completefrontend.com. Nothing was open yet.2const visit = {3 fetchStart: 0.8,4 domainLookupStart: 1.3,5 domainLookupEnd: 26,6 connectStart: 26,7 secureConnectionStart: 45.6,8 connectEnd: 83.8,9};10 11console.log(`Checking the cache: ${(visit.domainLookupStart - visit.fetchStart).toFixed(1)} ms`);12console.log(`DNS, finding the address: ${(visit.domainLookupEnd - visit.domainLookupStart).toFixed(1)} ms`);13console.log(`TCP, opening the line: ${(visit.secureConnectionStart - visit.connectStart).toFixed(1)} ms`);14console.log(`TLS, agreeing on a secret code: ${(visit.connectEnd - visit.secureConnectionStart).toFixed(1)} ms`);- Lines 2 to 9 hold six recorded times, in the order they happened.
- Each of lines 11 to 14 subtracts one start time from one end time.
- It prints
Checking the cache: 0.5 ms,DNS, finding the address: 24.7 ms,TCP, opening the line: 19.6 msandTLS, agreeing on a secret code: 38.2 ms.
Add them up, and 83 ms had passed before the browser could say a single word to the server. That's the answer to the first question of this lesson.
Now the good part. If you call the same shop again a minute later and the line is still open, you skip the lookup and the dialling. Browsers do the same. They keep connections open for a while and use them again. Here is the next page on the same site:
1// The next page on the same site, a moment later.2const visit = { fetchStart: 0.2, connectEnd: 0.2, requestStart: 0.6 };3 4console.log(`Setting up a connection: ${(visit.connectEnd - visit.fetchStart).toFixed(1)} ms`);5console.log(`The request left at ${visit.requestStart} ms.`);- Line 2 holds three recorded times.
fetchStartandconnectEndare the same: no time passed between them. - It prints
Setting up a connection: 0.0 msandThe request left at 0.6 ms.
When a connection is reused, every DNS and connection time equals fetchStart, so each job shows 0. The browser used the first page's connection again, which by then spoke HTTP/3, and sent the request almost at once.
Request, response, and the first byte
The line is open. At requestStart, the browser sends its request: "please send me this page". Then it waits while the server finds or builds the page. The moment the first byte of the answer arrives is responseStart. The moment the last byte arrives is responseEnd.
At a busy café you queue at the door, find a table, and then order. A friend asks: "How long until your food came?" You count from when you walked in, not from when you ordered. The queue and the table were part of your wait, even though the kitchen only started when you ordered.
- In real life: Walking in the door
- In JavaScript: The visit starts, at
startTime, which is 0 - In real life: Queueing and finding a table
- In JavaScript: Redirects, the cache, DNS, TCP and TLS
- In real life: The kitchen cooking your order
- In JavaScript: The server's own work,
requestStarttoresponseStart - In real life: Your first plate arriving
- In JavaScript:
responseStart: the first byte
Time to First Byte, or TTFB, counts the same way. web.dev defines it as "the time between starting navigating to a page and when the first byte of a response begins to arrive." So it holds everything before the request too, not just the server. It says most sites "should strive to have a TTFB of 0.8 seconds or less." The supporting metrics introduced TTFB; here you can take it apart:
1// The first visit to completefrontend.com again.2const visit = { startTime: 0, requestStart: 83.9, responseStart: 104.5, responseEnd: 161.3 };3 4console.log(`TTFB: ${visit.responseStart - visit.startTime} ms`);5console.log(`The server's own part: ${(visit.responseStart - visit.requestStart).toFixed(1)} ms`);6console.log(`Downloading the rest of the HTML: ${(visit.responseEnd - visit.responseStart).toFixed(1)} ms`);- Line 2 holds the first visit's request and response times.
- Line 4 is TTFB: the first byte, counted from the start. It prints
TTFB: 104.5 ms. - Line 5 is the server's own part, from the request leaving to the first byte. It prints
The server's own part: 20.6 ms. - Line 6 is the download of the rest. It prints
Downloading the rest of the HTML: 56.8 ms.
The kitchen took about 20 ms. The other 84 ms of the TTFB went on getting to the counter: the cache check, DNS, TCP and TLS. So a slow TTFB doesn't always mean a slow server. Subtract first, and you'll know which part to fix.
Parsing to first paint
The HTML is arriving. The browser doesn't wait for all of it. It starts reading as soon as the first bytes come in. Reading the HTML and building the page's tree of elements, the DOM, is called parsing.
A bookcase arrives in several boxes. You don't wait for the last box: you start building with the first one. When the frame stands up, you call it built. Then the shelves and the books go in. A friend walking in can see the bookcase long before the last book is on it.
- In real life: Building as the boxes arrive
- In JavaScript: Parsing the HTML while it downloads
- In real life: The frame stands up
- In JavaScript:
domInteractive, then DOMContentLoaded - In real life: The last book is on the shelf
- In JavaScript: The load event: images and other files have arrived
- In real life: A friend sees the bookcase
- In JavaScript: The first paint, which has its own clock
Three timestamps mark how far the document has got:
domInteractive: the browser has finished reading the HTML.DOMContentLoaded: an event that fires next, once any deferred scripts have run. Its times aredomContentLoadedEventStartanddomContentLoadedEventEnd.load: an event that fires when images, styles and other files have arrived too. It ends atloadEventEnd.
Painting isn't on this list. The moment the first text or image appears on screen is the first contentful paint, and it comes from a different API, Paint Timing. Here are the first visit's milestones, in time order:
1// The first visit again: each milestone, and the time it happened.2const milestones = [3 { what: "the last byte of the HTML arrived", ms: 161.3 },4 { what: "the browser finished reading the HTML", ms: 258.7 },5 { what: "DOMContentLoaded finished", ms: 258.7 },6 { what: "the load event finished", ms: 309.8 },7 { what: "the first text or image was painted", ms: 332 },8];9 10for (const { what, ms } of milestones.sort((a, b) => a.ms - b.ms)) console.log(`${ms} ms: ${what}`);- Lines 3 to 7 list five milestones with their recorded times. The first four come from the navigation entry, the last one from Paint Timing.
- Line 10 sorts them by time and prints one per line.
- It prints
161.3 ms: the last byte of the HTML arrivedand258.7 ms: the browser finished reading the HTML. Then258.7 ms: DOMContentLoaded finished,309.8 ms: the load event finishedand332 ms: the first text or image was painted.
On this small page, the first paint came just after the load event. On a page with big images, it would come long before, the way your friend sees the bookcase before the books are in. The two aren't tied together, so the course always measures paints separately from load.
The Navigation Timing diagram
You've now met every job. The Navigation Timing standard draws them all on one diagram, left to right, in the order they happen. Here it is, folded into the four stages:
Chrome's DevTools show the same jobs for any request. Open the Network panel, click the page's request, and open the Timing tab. The names are a little different. Read this table across: each row is one job, its Navigation Timing times, and its name in DevTools.
| Job | In Navigation Timing | In DevTools' Timing tab |
|---|---|---|
| DNS | domainLookupStart to domainLookupEnd. | DNS lookup. |
| Connection | connectStart to connectEnd, TLS included. | Initial connection. |
| TLS | secureConnectionStart to connectEnd. | SSL. |
| Waiting | requestStart to responseStart. | Request sent, then Waiting for server response. |
| Download | responseStart to responseEnd. | Content download. |
To read the times for any page, call performance.getEntriesByType("navigation"). An older object, performance.timing, still exists, but the standard lists it as obsolete. Try the new one on this very page:
1const [entry] = performance.getEntriesByType("navigation");2 3const phases = [4 ["DNS", entry.domainLookupStart, entry.domainLookupEnd],5 ["connection", entry.connectStart, entry.connectEnd],6 ["waiting", entry.requestStart, entry.responseStart],7 ["download", entry.responseStart, entry.responseEnd],8 ["to load", entry.responseEnd, entry.loadEventEnd],9];10for (const [name, start, end] of phases) console.log(name, start, end - start);Nothing read yet.
Press Look to draw this page's own visit, job by job.
Lines 3 to 9 of the panel's code pick a start time and an end time for each job. The last line prints how long each took.
Which phase?
Each problem below slows down one stage of a visit. Sort it into the stage it slows:
- A link to /blog/ that the server redirects to /blog
- A service worker that takes 200 ms to start
- A DNS answer that takes 300 ms to come back
- Setting up TLS with a server on another continent
- A slow database query that runs before the HTML is sent
- A server that builds the whole page before sending any of it
- A long script in the HTML that runs before DOMContentLoaded
- Large images that hold the load event back
Sort each problem by the stage it slows down.
Common mix-ups
- "TTFB is the server's time." No. It counts everything from the start of the visit to the first byte. On the first visit, the server's part was 20.6 of the 104.5 ms.
- "domInteractive means the user can use the page." No. It means the browser finished reading the HTML. Scripts may still be running, and nothing may be painted yet.
- "The load event is when the user sees the page." No. Painting has its own clock. It can come before load or after it.
- "A zero means the job was instant." No. It can also mean hidden (an unload or redirect on another origin), skipped (no service worker), or reused (a connection that was already open).
- "unload is a safe place to send analytics." No. On phones it often doesn't run at all.
- "performance.timing is the API to use." No. The standard lists it as obsolete. Use the navigation entry.
Practice exercises
Answer first, then use the hints.
The server now takes 400 ms longer to answer. What does the second line print?
// The first visit to completefrontend.com again.
const visit = { startTime: 0, requestStart: 83.9, responseStart: 504.5, responseEnd: 561.3 };
console.log(`TTFB: ${visit.responseStart - visit.startTime} ms`);
console.log(`The server's own part: ${(visit.responseStart - visit.requestStart).toFixed(1)} ms`);
console.log(`Downloading the rest of the HTML: ${(visit.responseEnd - visit.responseStart).toFixed(1)} ms`);The server's own part: 420.6 ms. TTFB grows by the same 400 ms. The download doesn't change.
On another page, the first paint comes at 200 ms. What does the second line print?
// The first visit again: each milestone, and the time it happened.
const milestones = [
{ what: "the last byte of the HTML arrived", ms: 161.3 },
{ what: "the browser finished reading the HTML", ms: 258.7 },
{ what: "DOMContentLoaded finished", ms: 258.7 },
{ what: "the load event finished", ms: 309.8 },
{ what: "the first text or image was painted", ms: 200 },
];
for (const { what, ms } of milestones.sort((a, b) => a.ms - b.ms)) console.log(`${ms} ms: ${what}`);200 ms: the first text or image was painted. A page can paint before the browser has finished reading the HTML: it paints what it has so far.
This time another site sent the visitor here, so both redirect times are 0. What does the first line print?
// A visit that a different site redirected here.
// The server answered: "this page lives at /web-performance now".
const visit = { redirectStart: 0, redirectEnd: 0, requestStart: 276.5, responseStart: 300.1 };
const redirect = visit.redirectEnd - visit.redirectStart;
const realRequest = visit.responseStart - visit.requestStart;
console.log(`Following the redirect: ${redirect.toFixed(1)} ms`);
console.log(`The real request, once it left: ${realRequest.toFixed(1)} ms`);Following the redirect: 0.0 ms. The redirect still happened, and still took time. It's just hidden.
In the recorded visit with a redirect, when did the load event finish, in ms?
469 ms. The detour alone took 274.6 ms of it.
Which phase of DevTools' Timing tab matches requestStart to responseStart?
Waiting for server response: about requestStart to responseStart. Content download comes after it.
A page sends its analytics in an unload handler. Which event should it listen for instead?
pagehide, or visibilitychange when the page becomes hidden. Both fire reliably, and neither blocks the back/forward cache.
Check your understanding
For each question, ask yourself which job it's about, and which two times mark its start and end.
Question 1 of 7What does the first line of the redirect example print?
Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript// A visit to /web-performance/ (with a slash at the end). // The server answered: "this page lives at /web-performance now". const visit = { redirectStart: 0.3, redirectEnd: 274.9, requestStart: 276.5, responseStart: 300.1 }; const redirect = visit.redirectEnd - visit.redirectStart; const realRequest = visit.responseStart - visit.requestStart; console.log(`Following the redirect: ${redirect.toFixed(1)} ms`); console.log(`The real request, once it left: ${realRequest.toFixed(1)} ms`);Choose an answer to see the explanation.
Question 2 of 7You arrive at a page from a search engine. Why is its unloadEventStart 0?
Choose an answer to see the explanation.
Question 3 of 7What does the second line of the first-byte example print?
Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript// The first visit to completefrontend.com again. const visit = { startTime: 0, requestStart: 83.9, responseStart: 104.5, responseEnd: 161.3 }; console.log(`TTFB: ${visit.responseStart - visit.startTime} ms`); console.log(`The server's own part: ${(visit.responseStart - visit.requestStart).toFixed(1)} ms`); console.log(`Downloading the rest of the HTML: ${(visit.responseEnd - visit.responseStart).toFixed(1)} ms`);Choose an answer to see the explanation.
Question 4 of 7In the recorded visit to a second page of the same site, why were DNS, TCP and TLS all zero?
Choose an answer to see the explanation.
Question 5 of 7What does TTFB count?
Choose an answer to see the explanation.
Question 6 of 7In the recorded first visit, did the first contentful paint come before the load event?
Choose an answer to see the explanation.
Question 7 of 7Why shouldn't a page send its analytics in an unload handler?
Choose an answer to see the explanation.
Key takeaways
- Loading a page is a chain of jobs: before the request, the connection, the request and response, then the document.
- Each job is the gap between two times in the navigation entry. Subtract them to find where the time went.
- A zero can mean instant, skipped, hidden, or reused. Unload and redirects on another origin are hidden.
- TTFB counts everything before the first byte, not just the server.
- Painting has its own clock, separate from DOMContentLoaded and load.
Remember the one-liner.
A page load is a relay of jobs; subtract two times to see how long each runner took.
Sources
The facts on this page come from these standards and docs:
- Navigation Timing Level 2, W3C.
- Deprecating the unload event, Chrome for Developers.
- Time to First Byte (TTFB), web.dev.
- PerformanceNavigationTiming, MDN.
- PerformancePaintTiming, MDN.
- RequestTimingView.ts: the Network panel's Timing tab, Chrome DevTools source.
Coming next: HTML parsing and the preload scanner. While the browser reads the HTML, a second reader runs ahead to start fetching files early. You'll see what it finds, and what hides files from it.