How a page loadsLesson
A page load is a chain of waits.
Between pressing Enter and seeing the first words, the browser finds the server, may be sent somewhere else, opens a connection, asks for the page and reads it, finding more files as it goes. Most of that time is spent waiting, and much of the waiting is yours to remove.
Before you begin
You type an address, press Enter, and for a moment nothing happens. That blank moment is not one slow thing. It is a chain of small waits, each one starting only when the one before it ends. Once you can name the links in the chain, you can see which ones your own HTML controls.
The journey from URL to pixels
A first visit to a page moves through five stages, in this order. The first three are waiting for messages to travel across the network. The last two are work the browser does with what arrived.
Read this figure as text
Five stages in order. Stages 1 to 3 wait on the network: 1, find the server by turning its name into an address; 2, open a connection and secure it; 3, ask for the page and wait for the first bytes. Stages 4 and 5 happen in the browser: 4, read the HTML and build the page's structure; 5, draw the first pixels.
Two things can make the chain longer. The server can answer "the page is somewhere else", which sends the browser back to the start for a new address. And the page can ask for files that are found only after other files arrive, so their waits line up one behind the other. Later visits can also make it shorter, because answers and files are reused. This lesson walks through the stages one at a time, then looks at each of those cases.
Find the server
The address you typed contains a domain name: the readable name of a site, such as thinkfe.dev. People remember names, but computers find each other by number. Every device on the internet has an IP address (Internet Protocol address), a number such as 203.0.113.10 that tells the network where to deliver data.
So before anything else, the browser must turn the name into a number. It asks the DNS (Domain Name System), the internet's lookup system for names.
The answer is kept in a cache: a stored copy the browser can reuse instead of asking again. That is why a second visit usually skips this stage. A page that loads fonts, images or scripts from other domain names needs one lookup for each different name.
How DNS finds an answer
The browser does not search the internet for the name itself. First it checks its own cache. If the answer is not there, it usually asks the computer's operating system, which keeps a cache of its own. Only if neither knows does the question leave your computer.
It goes to a DNS resolver: a server, usually run by your internet provider or by a public service, that looks names up on your behalf and remembers the answers. When many people use the same resolver, a popular name is usually already in its cache, and you get the answer after a single question and reply.
A resolver with nothing useful in its cache works its way down from the top of the DNS. It asks the servers at the very top which servers know about names that end in .dev. It asks those which servers know about thinkfe.dev. Then it asks one of them. That last server is an authoritative name server: a server that holds a domain's own records, the answers its owner has set.
Read this figure as text
A lookup for thinkfe.dev that no cache can answer. 1, the browser checks its own cache, and 2, the operating system's cache: no answer. 3, it asks a DNS resolver, whose cache has no answer either. 4, the resolver asks the servers at the top of the DNS, which reply: try the .dev servers. 5, it asks the .dev servers, which reply: try thinkfe.dev's own servers. 6, it asks thinkfe.dev's authoritative name servers, which reply 203.0.113.10 with a TTL of 300 seconds. The resolver keeps the answer and passes it to the browser. Each step happens only when every cache before it had no answer.
Every answer carries a TTL (time to live): the number of seconds the answer may be kept before someone has to ask again. If thinkfe.dev's record has a TTL of 300, the resolver may reuse the address for five minutes. Every cache along the way may keep the answer for at most that long; some keep it for less.
Open a connection
Knowing the address is not enough. The browser and the server first agree to talk, and every agreement costs a round trip: one message travelling across the network and the reply coming back. How long a round trip takes depends mostly on distance and on the network, not on how fast the server is.
The browser sets up a connection, an open channel that both sides agree to use. Setting one up takes a handshake, a short exchange of messages before any page data moves.
There are two handshakes. TCP (Transmission Control Protocol) is the set of rules that delivers data reliably and in order; its handshake is three messages and costs about one round trip. The second handshake is for TLS (Transport Layer Security), the layer that encrypts the connection and proves the server is genuine. With TLS 1.3, the current version, that adds one more round trip; older versions needed more.
Almost every site needs that second handshake because it uses HTTPS (HyperText Transfer Protocol Secure): HTTP (HyperText Transfer Protocol), the web's rules for requests and responses, sent over a connection encrypted with TLS. Every address that starts with https:// uses it.
The figure below uses the messages' real names. SYN, SYN-ACK and ACK are TCP's three messages; ClientHello, ServerHello and Finished open the TLS handshake. You do not need to remember the names. Count the round trips.
Read this figure as text
A first visit over HTTPS, modeled. Round trip 1: the browser asks the DNS resolver where thinkfe.dev is and gets back an IP address. Round trip 2: the TCP handshake, SYN to the server and SYN-ACK back. Round trip 3: the TLS 1.3 handshake, the browser's ACK and ClientHello, then the server's ServerHello through Finished. Round trip 4: the browser's Finished message with GET /, then the first bytes of the HTML. Only then can parsing begin.
Ask and wait
With an encrypted connection open, the browser finally sends its request for the page and waits. The server prepares the HTML and sends the first bytes back. The time from the start of the navigation, when you pressed Enter, to that first byte has a name: TTFB (Time to First Byte). It includes finding the server and opening the connection, not just the server's work.
You can watch these waits on any site. Every browser ships DevTools, developer tools for inspecting pages and their requests. Its network waterfall draws each request as a bar on a shared timeline, so you can see what waited for what. The lab walks you through it.
Explore
Step through a first visit
A first visit to https://thinkfe.dev
Model: first visit, nothing cached, TLS 1.3 full handshake over TCP. Round trips are counted, not timed. Newer setups can merge some steps; lesson 1.5 covers that.
Where is thinkfe.dev?
The browser knows the domain name but not the IP address, so it asks a DNS resolver.
This is a simulation for learning. It sends no network requests, and real timings vary by network and device.
When the answer is "look elsewhere"
Every request names a method: the kind of request it is. GET asks for something, such as a page, and POST sends something, such as a filled-in form. Every response starts with a status code: a three-digit number that says how the request went. 200 means "here it is" and 404 means "not found".
After the status code come the headers: named lines of extra information about the response, such as content-type: text/html, which says that the content that follows is a web page.
A status code from 300 to 399 usually means a redirect: an answer that carries no page to show, only a location header with a different address to ask instead. The browser follows it on its own, so you rarely notice, but it starts a new request. (304 is the exception in that range: it means "the copy you already have is still good", and lesson 1.6 covers it.)
HTTP/1.1 301 Moved Permanently
location: https://www.thinkfe.dev/The parts that matter: a status code and the new address. Any short note sent with it is not shown.
301 and 308 say the move is permanent, so browsers may remember it. 302 and 307 say it is temporary. 307 and 308 also guarantee that the browser repeats the request with the same method, so a form that was being sent is sent again to the new address instead of turning into a plain GET.
A redirect costs at least one round trip, because the browser has to ask again. When the new address is on another domain name, it usually costs much more: a DNS lookup for the new name and a new connection with both handshakes. Sending thinkfe.dev to www.thinkfe.dev (opens in a new tab), or the other way round, is the most common case.
Explore
Step through a redirect to another name
A link to thinkfe.dev that redirects to www.thinkfe.dev
Model: nothing cached. Server A holds thinkfe.dev and server B holds www.thinkfe.dev. TLS 1.3 over TCP. If both names were on one server whose certificate covers both, HTTP/2 or HTTP/3 might reuse the first connection (lesson 1.5). Round trips are counted, not timed.
Where is thinkfe.dev?
The link points at thinkfe.dev, so the browser asks the DNS resolver for that name first.
This is a simulation for learning. It sends no network requests, and real timings vary by network and device.
Redirects also stack. In our test in Chrome 156, https://www.mozilla.org (opens in a new tab) answered with a 302 to a page in the visitor's language: one more request before the first byte of the real page. Link to the final address wherever you can.
Read this figure as text
Round trips before the first byte of the page, modeled with nothing cached and the two names on different servers. Linked to https://www.thinkfe.dev: 4 round trips (DNS; TCP; TLS; GET → page). Linked to thinkfe.dev, redirected to www: 8 round trips (DNS, spent on a redirect; TCP, spent on a redirect; TLS, spent on a redirect; GET → 301, spent on a redirect; DNS; TCP; TLS; GET → page). Typed http://, no automatic upgrade (older browsers): 6 round trips (DNS; TCP, http, spent on a redirect; GET → 301, spent on a redirect; TCP; TLS; GET → page). Typed http://, Chrome upgrades it first: 4 round trips (DNS; TCP; TLS; GET → page).
Typing an address without https:// used to cost a redirect too. The browser asked over plain http://, which needs a connection of its own, and the server answered with a redirect to https://. That first request travelled unencrypted, so anyone on the network could read it or answer it with a fake page.
Chrome stopped doing that in version 115. For an address typed or linked as http://, it tries https:// first by itself and falls back to plain http:// only if the secure attempt fails. Addresses on your own computer, such as localhost or 127.0.0.1, were not upgraded in our test, so you will not see this while developing locally. DevTools shows this as a 307 that Chrome made itself, with the reason HttpsUpgrades. The site that keeps the browsers' shared list of HTTPS-only sites says Safari upgrades too.
The fallback is the weak spot: an attacker who can make the https:// attempt fail can still push the browser back to plain http://. HSTS (HTTP Strict Transport Security) closes that gap. It is a response header that tells the browser "for the next so many seconds, use only https:// for this site". During that time the browser never falls back: if the secure connection fails, it shows an error instead. It also protects visitors whose browsers do not upgrade by themselves.
strict-transport-security: max-age=31536000; includeSubDomainsmax-age is in seconds: 31,536,000 is one year. includeSubDomains covers every name under the site too.
First visit
Upgraded by ChromeA 307 with the reason HttpsUpgrades: Chrome tried https:// on its own and it worked. The HTTPS reply carried an HSTS header.Second visit
Upgraded by HSTSA 307 with the reason HSTS: Chrome uses https:// because the site told it to, and it would not fall back to http://.http://neverssl.com, a site with no HTTPS, got the same upgrade attempt, which failed, and Chrome fell back to plain http://. Browsers also ship a preload list of sites that are always HTTPS: http://github.com was upgraded with the reason HSTS on our very first visit.
Read the HTML
The browser does not wait for the whole document. It starts parsing as soon as bytes arrive: reading the HTML text and turning it into a structure it can work with. That structure is the DOM (Document Object Model), the browser's tree of the page's elements. CSS gets its own structure, the CSSOM (CSS Object Model), which holds every style rule in a form the browser can look up.
<!doctype html>
<html lang="en">
<head>
<link rel="stylesheet" href="/style.css" />
<script src="/app.js"></script>
</head>
<body>
<h1>Hello</h1>
</body>
</html>Lines 4 and 5 tell the browser it needs two more files before this page can be shown correctly.
Reading this file, the browser discovers that it needs style.css and app.js. It does not have to reach those lines before asking for them: a preload scanner reads ahead in the HTML and starts those downloads early.
Send the head first
The browser can parse HTML as it arrives, but only if the server sends it that way. Many servers build the whole page first, for example after loading products from a database, and send nothing until it is finished. Until then the browser has no bytes, so it cannot discover the stylesheet either.
The head of most pages does not depend on that data. An early flush sends the first part of the HTML, usually the head, as soon as it is ready, and the rest when the page is built. The browser starts parsing, finds the stylesheet and starts downloading it while the server is still working.
app.get("/", async (request, response) => {
response.setHeader("content-type", "text/html");
response.write(`<!doctype html><html><head><link rel="stylesheet" href="/style.css"></head><body>`);
const products = await loadProducts(); // the slow part
response.end(`${renderProducts(products)}</body></html>`);
});The head goes out on line 3, before the slow database call. The rest follows when it is ready.
We checked this in Chrome 156 with a local server that took one second to build the body. When it waited and sent everything at the end, the stylesheet request started after about 1,000 milliseconds. When it sent the head first, the stylesheet request started after a few milliseconds and had arrived long before the body did.
Read this figure as text
A model: the server needs 1000 milliseconds to build the page and the stylesheet takes 300 milliseconds to download. Sending everything at the end: the head arrives at 1000 ms, so the stylesheet downloads from 1000 to 1300 ms and the first text appears at 1300 ms. Sending the head first: the head arrives at once, the stylesheet downloads from 0 to 300 ms while the server works, and the first text appears when the body arrives at 1000 ms.
There is a price. The status code and headers go out with the first bytes, so once the head is sent, the server can no longer answer with a redirect or an error code if something fails later. It has to show the error inside the page instead.
What blocks the first paint
Two kinds of file hold back the first paint, in different ways.
A stylesheet is render-blocking: the browser will not draw anything until the stylesheet has arrived and been processed, so you never see the page with the wrong styles. Parsing can carry on while it waits.
A plain script tag is parser-blocking: parsing stops at the tag until the script has downloaded and run, because the script might change the page. A script also waits for stylesheets above it, since it might read styles. So a plain script in the head delays the first paint too: until it has run, nothing after it is parsed, and the browser has nothing more to draw.
Two attributes change that. With defer, the script downloads while parsing continues and runs after parsing finishes, in the order the scripts appear. With async, it downloads while parsing continues and runs as soon as it arrives, in no guaranteed order.
Read this figure as text
Three rows share one modeled timeline. No attribute: parsing stops when the script tag is reached, waits while the script downloads and runs, then continues, so parsing finishes last. async: the script downloads while parsing continues, then parsing pauses briefly while it runs as soon as it arrives. defer: the script downloads while parsing continues and runs only after parsing has finished, so parsing never pauses.
<script src="/app.js"></script>
<script src="/app.js" defer></script>
<script src="/analytics.js" async></script>| Criterion | No attribute | async | defer |
|---|---|---|---|
| Pauses parsing while it downloads | Yes | No | No |
| When it runs | When parsing reaches it, once it has downloaded and earlier stylesheets are ready | As soon as it has downloaded | After parsing finishes |
| Order between scripts | Document order | Not guaranteed | Document order |
| Good for | Rare cases that must run before the rest of the HTML | Independent scripts, like analytics | Most page scripts |
No attribute
- Pauses parsing while it downloads
- Yes
- When it runs
- When parsing reaches it, once it has downloaded and earlier stylesheets are ready
- Order between scripts
- Document order
- Good for
- Rare cases that must run before the rest of the HTML
async
- Pauses parsing while it downloads
- No
- When it runs
- As soon as it has downloaded
- Order between scripts
- Not guaranteed
- Good for
- Independent scripts, like analytics
defer
- Pauses parsing while it downloads
- No
- When it runs
- After parsing finishes
- Order between scripts
- Document order
- Good for
- Most page scripts
For most page scripts, defer starts the download early, keeps parsing moving and keeps order.
Everything the browser must finish before it can draw for the first time is the critical rendering path. The moment it does draw is called FCP (First Contentful Paint): the first text or image appears. Making the critical rendering path shorter makes FCP earlier.
Not every tag blocks
Those two rules have exceptions in both directions. Some stylesheets do not block drawing, and some scripts that you might expect to be harmless do block parsing.
A stylesheet's media attribute says when it applies. With media="print", it applies only when the page is printed. The browser still downloads it, but does not wait for it before drawing the screen. In our Chrome 156 test, a print stylesheet that took two seconds did not hold back a page that drew its first text at about 220 milliseconds.
A module script is a script loaded with type="module", which lets it import other scripts. Module scripts are deferred without being asked: they download while parsing continues and run after it finishes. Only async changes that: an async module script runs as soon as it is ready.
An inline script is a script written directly inside the HTML, with no src file to download. It still pauses parsing while it runs, and it still waits for any stylesheet above it that is still loading. defer and async have no effect on it. (An inline script with type="module" is the exception: like other module scripts, it is deferred unless it is marked async.)
<link rel="stylesheet" href="/screen.css" />
<link rel="stylesheet" href="/print.css" media="print" />
<script type="module" src="/app.js"></script>
<p>Before</p>
<script>document.title = "Ready";</script>
<p>After</p>Chrome reports its own decision for every file, and DevTools shows it in the Render-blocking column. We loaded a page with each kind of tag in Chrome 156 and read the label it gave each one:
| Tag | Chrome's label | Why |
|---|---|---|
| A stylesheet | blocking | Drawing waits for styles |
A stylesheet with media="print" | non-blocking | It does not apply to the screen |
| A plain script in the head | blocking | Parsing stops until it has run |
A script with defer or async | non-blocking | It downloads while parsing continues |
| A module script | non-blocking | Deferred by default |
| An image | non-blocking | Text can be drawn before the image arrives |
Files found late: request chains
The preload scanner can only start downloads for files named in the HTML. Some files are named inside other files, so the browser cannot ask for them until the first file has arrived. A file found that way, and everything behind it, forms a request chain: requests that have to happen one after another because each one is found inside the one before.
A stylesheet can load another stylesheet with @import. The browser learns about the second file only when it has downloaded and read the first one. Both block drawing, so their waits add up.
@import url("/b.css");
p { color: green; }b.css cannot be requested until a.css has arrived.
In our Chrome 156 test, each stylesheet took 400 milliseconds to arrive. With @import, b.css was requested only at about 415 milliseconds, when a.css had arrived, and the first text appeared at about 830 milliseconds. With both stylesheets linked from the HTML, both requests started within the first 10 milliseconds and the first text appeared at about 420.
Read this figure as text
Measured in Chrome 156, each stylesheet held for 400 milliseconds by a local server. With @import: index.html from 0 to 4 ms, a.css from 9 to 414 ms, b.css (from a.css) from 416 to 818 ms; first text at 828 ms. Both linked from the HTML: index.html from 0 to 1 ms, a.css from 6 to 414 ms, b.css from 6 to 411 ms; first text at 420 ms.
Fonts make the most common chain. A web font is a font file the page downloads, instead of one already installed on the device. It is named only inside a stylesheet, and the browser asks for it only when some text on the page uses it. So the font waits for the HTML, then the stylesheet, then the browser's discovery that text needs it.
While a web font is loading, browsers hide the text that uses it for a short time, so you do not see it jump from one font to another. In our Chrome 156 test, with a font that never arrived, the text stayed invisible for at least 1.5 seconds and then appeared in a fallback font: the next font in the list, one already on the device. A font that was declared but not used by any text was never requested.
The fix is to make chains shorter: link every stylesheet the first paint needs from the HTML, instead of importing it. For files that can only be named inside other files, such as fonts, the HTML can also announce them early with <link rel="preload">, which lesson 2G.2 covers.
Two milestones: DOMContentLoaded and load
The browser marks two moments in every page load with events that your scripts can wait for.
DOMContentLoaded fires when the whole HTML has been parsed and every deferred script, including module scripts, has run. It does not wait for images, and it does not wait for async scripts. Code that needs the whole DOM can start here.
The load event fires later, when everything the page started loading has finished: images, stylesheets, scripts and any pages embedded in it. Images marked loading="lazy", which load only when they come near the visible part of the page, do not hold it back.
Read this figure as text
Measured in Chrome 156 on one page. The deferred script arrived and ran at 614 ms, and DOMContentLoaded fired at 614 ms. The async script arrived and ran at 1214 ms, after DOMContentLoaded. The image finished at 2013 ms, and the load event fired at 2014 ms. The first text had appeared at 40 ms.
In that test, a deferred script that took 600 milliseconds to arrive ran at about 614 milliseconds, and DOMContentLoaded fired straight after it. An async script that took 1.2 seconds ran at about 1,214, well after DOMContentLoaded. An image that took 2 seconds kept the load event waiting until about 2,014 milliseconds, although the first text had appeared at 40.
Measuring each wait with Navigation Timing
You do not need to guess where the time went. Navigation Timing is a browser feature that records a timestamp for every stage of the page load, from the redirects to the load event, in milliseconds since the navigation started.
const [page] = performance.getEntriesByType("navigation");
console.table({
redirects: page.redirectEnd - page.redirectStart,
dns: page.domainLookupEnd - page.domainLookupStart,
connection: page.connectEnd - page.connectStart,
ttfb: page.responseStart,
html: page.responseEnd - page.responseStart,
domContentLoaded: page.domContentLoadedEventStart,
load: page.loadEventStart,
});Each line subtracts two timestamps. A stage that did not happen, such as a DNS lookup answered from the cache, shows 0.
Each pair of timestamps belongs to one link in the chain:
| Stage in this lesson | Timestamps |
|---|---|
| Redirects | redirectStart to redirectEnd, and redirectCount for how many |
| Find the server | domainLookupStart to domainLookupEnd |
| Open a connection | connectStart to connectEnd; the TLS part starts at secureConnectionStart |
| Ask and wait | requestStart to responseStart; responseStart on its own is the TTFB |
| HTML arriving | responseStart to responseEnd |
| The two milestones | domContentLoadedEventStart, loadEventStart |
When a redirect changes the scheme (the http:// or https:// at the start), the domain name or the port (a number that picks which service on the server answers, such as 443 for https://), the browser hides the redirect timestamps and shows them as 0, so one site cannot learn about another. Even http:// to https:// on the same name counts, so redirectCount is 0 after Chrome's own upgrade too. The Network panel still lists the redirect.
The second visit
Everything so far described a first visit. Come back a little later and most of the chain can be skipped.
The DNS answer is still in a cache until its TTL runs out. The connection may still be open, so no handshakes are needed. Files the server allowed the browser to keep can come straight from the browser's cache, and lesson 1.6 shows how a server decides that. An HSTS rule is remembered too, so a typed http:// address goes straight to https:// with no chance of falling back.
Read this figure as text
The lesson's five stages, twice. First visit: Find the server (DNS lookup), Open a connection (TCP + TLS), Ask and wait (request), Read the HTML (fetch every file), Draw (first paint). Second visit: Find the server skipped (cached answer), Open a connection skipped (reused), Ask and wait runs (request), Read the HTML runs (kept files reused), Draw runs (first paint).
First visit
DNS 7 ms · connection 62 ms · TTFB 103 msA full lookup, TCP and TLS handshakes, then the request.Second visit
DNS 0 ms · connection 0 ms · TTFB 36 msThe answer was cached and the connection was reused, so only the request was left.These are one test's numbers on one network. Yours will differ; the zeros are the point.
The Back button can skip even more. Browsers can keep the whole page you just left in memory and show it again at once, without loading anything; lesson 1.6 explains when they may. That is also why the lab asks you to measure in a private window: there, nothing from earlier visits is reused.
Deep dive · optionalWhat senior engineers add
The preload scanner reads only HTML markup. Files that are added by JavaScript, images set as CSS backgrounds and pages built entirely by JavaScript are invisible to it, so those downloads start late. If a file matters for the first paint, put it in the HTML.
You will often hear that the first response should fit in about 14 kilobytes. Treat it as a rule of thumb: the number comes from how much data a new TCP connection may send before waiting for a reply, and real setups vary. The idea that holds is that critical HTML and CSS should arrive early and be small.
Each extra domain name can mean another DNS lookup and another connection with its own handshakes. <link rel="preconnect"> asks the browser to do the lookup and both handshakes early, for a server you know you will need; lesson 2G.2 covers it, and lesson 1.5 covers how newer versions of HTTP combine some of these steps.
A server that is still building the page can send a 103 Early Hints response first. It names files the page will need, so the browser can start fetching them before the real response arrives, which helps even when the server cannot flush its head early. They are only hints: the real response still carries its own headers. Navigation Timing's responseStart then records when the 103 arrived, not the real response. Lesson 2G.2 covers it.
Deep dive · optionalHTTPS everywhere, and pages sent in pieces
Joining the HSTS preload list needs a valid certificate (the file that proves the server is genuine during the TLS handshake), a redirect from http:// to https://, every name under the domain served over HTTPS, and a header with a max-age of at least one year, includeSubDomains and a preload flag. Leaving the list again can take months to reach every browser. The list's own site is blunt about it: "While HSTS is recommended, HSTS preloading is not recommended". With browsers upgrading http:// themselves, preloading adds protection mainly where those upgrades fail.
Turn includeSubDomains on only when every name under the domain works over HTTPS, or visitors will no longer be able to open the names that do not.
Over HTTP/1.1, a server that sends a page in pieces before it knows the total length uses chunked transfer coding: each piece is sent with its own size. HTTP/2 and HTTP/3 split every response into pieces anyway, so they need no such coding.
Tracks your place during this browser session. Sign in to save it to your account.
Next stepLab
See the chain for yourself.
Change where a script runs and watch what it can see, then measure real page loads in your own browser: the waterfall, an HSTS redirect and every stage's timestamps.
Open the lab