By the end you'll know
- What a browser does between receiving HTML and showing a page
- Why one page turns into many requests, and in what order
- What blocks a page from appearing, and how
deferfixes it - The difference between first paint, DOM ready and fully loaded
- How caching works on a second visit, and why the browser and server can disagree about it
- How to watch all of this in your browser's developer tools
Before you start
- You need: Networking Lesson 1, which ends where this one starts: its step 6, "Show it". Python 3,
curl, and a browser with developer tools (Chrome, Edge, Firefox or Safari). - Optional: Node.js and Playwright, to run the timing script below. Your browser's developer tools show the same things by hand.
- The example: Juniper & Rye is a made-up neighbourhood bakery. Every lesson in this track builds its site a piece at a time. The files, server and every line of output below are real.
The short version
- HTML is a list of instructions, not a picture. The browser reads it top to bottom and builds a tree of the page, the DOM.
- One page becomes many requests. The bakery's 661-byte HTML file led to four more: a stylesheet, a script, an image and the menu data.
- The browser fetches in parallel, but it won't paint until it has the CSS and any script that isn't marked
deferorasync. - One slow script without
deferkept the bakery's page blank for 1.56 seconds instead of 0.14. - On a second visit, the browser asks "has this changed?" and the server can answer
304 Not Modifiedwith no body. It's faster, but it's still a round trip. - Developer tools show all of it: the Network tab for requests, the Performance panel for rendering.
Where the Networking track left off
Networking Lesson 1 followed one request through six steps: look up the name, connect, secure the connection, ask, answer, show it. Its last step, "Show it", was one sentence: the browser turns the content into the page you see, and usually repeats steps 1 to 5 for every image, font and script on it.
That one sentence is most of what a web developer works on. It's where a page is fast or slow, where it works with a keyboard or doesn't, and where it shows the right content or a spinner. This track lives there.
The bakery's first page
Juniper & Rye's site starts as six small files:
site/
├── index.html the page
├── style.css how it looks
├── app.js fetches today's menu and adds it to the page
├── menu.json today's menu, as data
├── loaf.svg a picture of a loaf
└── slow.js a script the server sends slowly, for an experiment
The page:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Juniper & Rye Bakery</title>
<link rel="stylesheet" href="style.css">
<script src="app.js" defer></script>
</head>
<body>
<header>
<h1>Juniper & Rye</h1>
<p>Sourdough, rye and pastries, baked every morning on Alder Street.</p>
</header>
<main>
<img src="loaf.svg" alt="A round sourdough loaf" width="320" height="200">
<h2>Today's bakes</h2>
<ul id="menu"><li>Loading the menu...</li></ul>
</main>
<footer><p>Open 7am to 2pm, Tuesday to Sunday.</p></footer>
</body>
</html>
<head>holds information about the page: its character set, how to size it on phones (viewport), its title, and the other files it needs.<link rel="stylesheet">and<script src>each point to another file the browser must fetch.defertells the browser: fetch this script now, but don't run it until the page has been read. Remember it; it matters below.<body>is the content. The menu starts as "Loading the menu..." andapp.jsreplaces it.
app.js fetches the menu and puts it on the page:
// Fetch today's menu and put it on the page.
fetch("menu.json")
.then((r) => r.json())
.then((items) => {
const list = document.getElementById("menu");
list.innerHTML = items.map((i) => `<li>${i.name}: $${i.price.toFixed(2)}</li>`).join("");
});
fetch asks the server for menu.json, r.json() turns the answer into JavaScript objects, and the last step finds the list by its id and replaces its contents with one line per item. Lesson 3 covers JavaScript properly.
A server that shows its work
Python comes with a web server. The lesson's version adds three things to it: a timestamp on every request, a 1.5-second delay for slow.js, and a header telling browsers to check before reusing a saved copy.
import http.server, time, sys
START = time.time()
class Handler(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header("Cache-Control", "no-cache") # always ask before reusing
super().end_headers()
def do_GET(self):
if self.path.startswith("/slow.js"):
time.sleep(1.5)
super().do_GET()
def log_message(self, fmt, *args):
sys.stderr.write(f"{(time.time() - START) * 1000:8.0f} ms {self.command} {self.path} -> {args[1]}\n")
if __name__ == "__main__":
http.server.ThreadingHTTPServer.allow_reuse_address = True
srv = http.server.ThreadingHTTPServer(("127.0.0.1", 8080),
lambda *a, **k: Handler(*a, directory="site", **k))
print("Serving Juniper & Rye on http://127.0.0.1:8080", flush=True)
srv.serve_forever()
SimpleHTTPRequestHandlerserves files from a folder, heresite.end_headersaddsCache-Control: no-cacheto every answer. Despite its name,no-cachedoesn't mean "don't save it"; it means "save it, but ask me before using it again".do_GETwaits 1.5 seconds before sendingslow.js, to stand in for a slow third-party script.log_messageprints how many milliseconds after start each request arrived, and the status code sent.ThreadingHTTPServerhandles requests at the same time, the way a real server does. Without it, the slow script would hold up every other file and hide what the browser is doing.127.0.0.1means only your own computer can reach it.
(Your machine)
python3 serve.py
Leave it running, and open a second terminal.
Step 1: what arrives
(Your machine)
curl -sI http://127.0.0.1:8080/
curl -s http://127.0.0.1:8080/ | head -9
-I asks for the headers only; -s hides curl's progress bar. The second command fetches the page and shows its first nine lines.
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.13.18
Date: Sun, 08 Nov 2026 03:01:23 GMT
Content-type: text/html
Content-Length: 661
Last-Modified: Sun, 08 Nov 2026 03:01:11 GMT
Cache-Control: no-cache
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Juniper & Rye Bakery</title>
<link rel="stylesheet" href="style.css">
<script src="app.js" defer></script>
</head>
200 OK: it worked.HTTP/1.0is old; Python's simple server uses it, while real sites use HTTP/2 or HTTP/3 (Networking Lesson 4).Content-type: text/htmltells the browser to treat this as a web page, not to download it as a file.Content-Length: 661: the whole page is 661 bytes.Last-Modifiedis when the file last changed. The browser will use it on the next visit.
This is everything curl sees. It doesn't read the HTML, so it never asks for style.css or the menu. And look at the menu in the raw page: it still says "Loading the menu...". A program that reads only the HTML, like curl, some search engine crawlers, or a link preview, never sees the menu. Lesson 4 comes back to this.
Step 2: the browser reads the page
A browser does much more with the same 661 bytes. To see it, a short Playwright script loads the page in a real browser and records every request, in order, with its timing:
// Load a page in a real browser and report what it fetched, in order, and when it painted.
const { chromium } = require('playwright');
const url = process.argv[2];
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const rows = [];
page.on('requestfinished', async (req) => {
const t = req.timing();
const res = await req.response();
rows.push({ start: t.startTime, path: new URL(req.url()).pathname, type: req.resourceType(),
status: res.status(), ms: Math.round(t.responseEnd) });
});
for (const visit of ['first visit', 'reload']) {
rows.length = 0;
if (visit === 'first visit') await page.goto(url, { waitUntil: 'networkidle' });
else await page.reload({ waitUntil: 'networkidle' });
const nav = await page.evaluate(() => {
const n = performance.getEntriesByType('navigation')[0];
const fcp = performance.getEntriesByName('first-contentful-paint')[0];
return { dcl: Math.round(n.domContentLoadedEventEnd), load: Math.round(n.loadEventEnd),
fcp: fcp ? Math.round(fcp.startTime) : null };
});
const t0 = Math.min(...rows.map(r => r.start));
console.log(`-- ${visit}: ${url}`);
for (const r of rows.sort((a, b) => a.start - b.start))
console.log(` +${String(Math.round(r.start - t0)).padStart(5)} ms ${r.status} ${r.type.padEnd(10)} ${r.path.padEnd(16)} ${String(r.ms).padStart(5)} ms`);
console.log(` first paint ${nav.fcp} ms · DOM ready ${nav.dcl} ms · fully loaded ${nav.load} ms`);
console.log(` menu on the page: ${JSON.stringify(await page.$$eval('#menu li', els => els.map(e => e.textContent)))}`);
}
await browser.close();
})();
page.on('requestfinished')runs for every file the browser fetches, and records when it started, its type, its status and how long it took.getEntriesByType('navigation'), on the browser'sperformanceobject, is its own record of the page load.first-contentful-paintis when anything first appeared on screen.page.$$eval('#menu li', ...)reads the menu as it ended up on the page, after the scripts ran.- The loop loads the page twice: a first visit, then a reload.
(Your machine)
node watch.js http://127.0.0.1:8080/
The first visit:
-- first visit: http://127.0.0.1:8080/
+ 0 ms 200 document / 14 ms
+ 41 ms 200 script /app.js 22 ms
+ 41 ms 200 stylesheet /style.css 20 ms
+ 43 ms 200 image /loaf.svg 20 ms
+ 109 ms 200 fetch /menu.json 7 ms
first paint 144 ms · DOM ready 114 ms · fully loaded 115 ms
menu on the page: ["Country sourdough: $8.50","Dark rye: $9.00","Cardamom bun: $4.25"]
Here's what happened, in order:
- The HTML arrived (14 ms). The browser starts parsing it: reading the tags top to bottom and building the DOM (Document Object Model), a tree of every element on the page. The DOM is what scripts read and change.
- It found three more files and asked for all of them at once, 41 to 43 ms in: the stylesheet, the script and the image. Browsers don't wait to finish one file before asking for the next. A preload scanner reads ahead in the HTML just to find files to fetch early.
- The stylesheet became the CSSOM (CSS Object Model): the rules for how each element looks. The browser won't paint without it, or you'd see the page unstyled and then jump. That's why CSS is called render-blocking.
- DOM ready at 114 ms. The whole HTML had been read and the DOM built. Only now did the deferred script,
app.js, run. app.jsasked for the menu at 109 ms. This request isn't in the HTML at all; a script created it. The preload scanner can't find requests like this, so they always come last.- First paint at 144 ms. The browser combined the DOM and CSSOM into what to show, worked out where everything goes (layout), and drew it (paint).
- The menu replaced "Loading the menu...". Changing the DOM makes the browser lay out and paint again.
Three different moments, three different measures:
| Measure | What it means | Here |
|---|---|---|
| First (contentful) paint | Something appeared on screen | 144 ms |
DOM ready (DOMContentLoaded) | The HTML has been read and deferred scripts have run | 114 ms |
Fully loaded (load) | The page and its images, styles and scripts have arrived | 115 ms |
Here, the first paint came after the page was "fully loaded": on a fast local connection, everything arrived before the browser got around to drawing. On a real network, the order often flips, and users see the page well before load. Lesson 9 uses the measures that track what people actually experience.
Step 3: one script that blocks
blocking.html is the same page with one more line in the <head>, a script without defer, served slowly:
<script src="slow.js"></script>
<script src="app.js" defer></script>
(Your machine)
node watch.js http://127.0.0.1:8080/blocking.html
-- first visit: http://127.0.0.1:8080/blocking.html
+ 0 ms 200 document /blocking.html 7 ms
+ 19 ms 200 stylesheet /style.css 14 ms
+ 20 ms 200 script /slow.js 1513 ms
+ 21 ms 200 image /loaf.svg 15 ms
+ 25 ms 200 script /app.js 17 ms
+ 1529 ms 200 fetch /menu.json 3 ms
first paint 1560 ms · DOM ready 1531 ms · fully loaded 1531 ms
menu on the page: ["Country sourdough: $8.50","Dark rye: $9.00","Cardamom bun: $4.25"]
First paint went from 144 ms to 1,560 ms. For a second and a half, the visitor saw a blank page.
- The preload scanner still did its job. The stylesheet, the image and
app.jswere all requested within 25 ms, in parallel withslow.js, and all arrived within 40 ms. - But a plain
<script>stops the parser. When the browser reaches a script withoutdeferorasync, it must stop reading the HTML, wait for the script to arrive, and run it, because the script could change the page that follows. Nothing below it is built or painted until it's done. - The menu came last.
app.jswas ready at 42 ms, but deferred scripts run after the parser finishes, and the parser was waiting onslow.js. The menu request went out at 1,529 ms.
The fix is one word. defer (run after parsing, in order) or async (run as soon as it arrives, in any order) lets the browser keep reading and painting:
| Tag | Fetches | Runs | Blocks the page? |
|---|---|---|---|
<script src> | When the parser reaches it | Immediately, before the parser continues | Yes |
<script src defer> | In parallel | After parsing, in the order written | No |
<script src async> | In parallel | As soon as it arrives, in any order | Briefly, while it runs |
<script type="module"> | In parallel | Like defer | No |
Most real slow scripts aren't your own: they're analytics, chat widgets, ads and tag managers, loaded from someone else's server. One of them having a bad day can blank your page.
Step 4: the second visit
The script's second load is a reload of the same page:
-- reload: http://127.0.0.1:8080/
+ 0 ms 200 document / 6 ms
+ 17 ms 304 stylesheet /style.css 6 ms
+ 18 ms 304 script /app.js 16 ms
+ 19 ms 304 image /loaf.svg 13 ms
+ 35 ms 200 fetch /menu.json 2 ms
first paint 40 ms · DOM ready 36 ms · fully loaded 36 ms
menu on the page: ["Country sourdough: $8.50","Dark rye: $9.00","Cardamom bun: $4.25"]
First paint dropped from 144 ms to 40 ms. On the first visit, the browser saved each file in its cache, with the Last-Modified date. This time, because of no-cache, it asked the server about each one: "has this changed since that date?" (an If-Modified-Since request). The server said 304 Not Modified, with no body, and the browser used its saved copy.
Now look at the server's own log for the same reload:
3069 ms GET / -> 304
3084 ms GET /style.css -> 304
3092 ms GET /loaf.svg -> 304
3094 ms GET /app.js -> 304
3101 ms GET /menu.json -> 304
The server sent 304 for the page and the menu too, but the browser reported both as 200. For the main page and for fetch, the browser handles the 304 itself and hands the page its cached copy as an ordinary 200 response. Neither view is wrong; they're answering different questions. The server log says what crossed the network; the browser says what the page received. When you're debugging caching, look at both.
And one more thing the reload of blocking.html showed:
+ 11 ms 304 script /slow.js 1505 ms
A 304 for slow.js still took 1.5 seconds. "Has this changed?" is a round trip, and a slow server is slow to say "no". no-cache always asks. To skip the question entirely for a while, a server sends Cache-Control: max-age= with a number of seconds; until that time is up, the browser uses its copy without asking at all. That's how sites make repeat visits nearly instant, and it's why files that change get new names (like app.3f9a1c.js) instead of new contents. Lesson 9 comes back to this.
Watch it yourself in developer tools
You don't need Playwright to see any of this. In Chrome or Edge (Firefox and Safari are similar):
- Open
http://127.0.0.1:8080/blocking.html, then open developer tools (F12, orCmd+Option+Ion a Mac) and choose Network. - Reload. Each row is a request. The Waterfall column shows when each started and finished;
slow.jshas the long bar. - Tick Disable cache to see a first visit again; untick it to see 304s.
- Choose Performance, click the reload button in that panel, and look at the Frames row: the page stays blank until
slow.jsis done. - Use the throttling menu (No throttling to Slow 4G) to see the same page on a slow phone connection. Everything on this page gets slower; the order doesn't change.
In real systems
- Real pages make dozens to hundreds of requests. Fonts, images, analytics and widgets add up, and each third-party domain needs its own DNS lookup, connection and TLS setup (Networking Lessons 5 and 9).
- HTTP/2 and HTTP/3 send many requests over one connection at once, which is why the parallel fetching above scales to large pages.
- Content added by JavaScript is invisible to anything that doesn't run it. The bakery's menu isn't in its HTML. Frameworks like Next.js fix that by rendering the HTML on the server, which is Lesson 4.
- CDNs (content delivery networks) keep cached copies of files close to visitors, so even a first visit is fast. Vercel, in Lesson 8, does this for you.
- "Fast on my machine" means little.
127.0.0.1has no real network. Test with throttling, or on a real phone.
Spot the problem
Work out what's wrong before you read the explanation.
"We added a chat widget and nothing changed." A small shop adds a customer-chat widget by pasting the vendor's one-line snippet, a plain <script> tag pointing at the vendor's server, into the <head> of every page. The team checks the site on office Wi-Fi, sees no difference, and ships it. A week later, on the day the vendor's servers are slow, the shop's pages show a blank white screen for six seconds, and sales drop. What's wrong? A script without defer or async stops the browser from reading and painting the page until it arrives, so the shop's speed is now tied to someone else's server. On a good day, the delay was too small to notice; on a bad day, it was the whole page. Three fixes: add defer or async (or load the widget after the page has painted); test third-party scripts at their worst, using developer tools to block or throttle that domain; and keep a list of every third-party script on the site, with an owner, the way Lesson 6 of the AI governance track keeps an inventory.
Keep this
| Step | What happens | What can go wrong |
|---|---|---|
| Parse HTML | Read tags top to bottom; build the DOM | A plain <script> stops it |
| Find files | The preload scanner requests CSS, scripts and images early, in parallel | Files added by scripts are found late |
| Build styles | CSS becomes the CSSOM | CSS blocks painting until it arrives |
| Layout and paint | Work out positions; draw pixels | Changing the DOM means doing it again |
| Run scripts | defer after parsing; async on arrival | Content they add isn't in the HTML |
| Next visit | Cached files checked with the server (304) or reused (max-age) | A 304 is still a round trip |
Where you are now
You can follow a page from the moment its HTML arrives: parsing into the DOM, fetching other files in parallel, building styles, laying out and painting, running scripts, and reusing files on the next visit. You've seen one script hold a page blank for a second and a half, and fixed it with one word. The next lesson writes the bakery's page properly: HTML that means something, and CSS that works on a phone.
Check yourself
What is the DOM?
Why did blocking.html stay blank for 1.5 seconds?
What does defer do?
Why didn't curl's copy of the page show the menu?
On reload, the server logged 304 for the page but the browser said 200. Why?
Why was slow.js still slow on reload, despite a 304?
Key terms
- Parsing: reading HTML tags in order and building the page's structure from them.
- DOM (Document Object Model): the tree of elements a browser builds from HTML; scripts read and change it.
- CSSOM (CSS Object Model): the browser's model of the page's styles, built from CSS.
- Preload scanner: the part of the browser that reads ahead in the HTML to fetch files early.
- Render-blocking: something the browser must have before it paints, like CSS and plain scripts.
- Layout and paint: working out where everything goes, then drawing it.
- First contentful paint: when anything first appears on screen.
deferandasync: script attributes that let the browser keep reading and painting while the script loads.- Cache: saved copies of files, reused on later visits.
- 304 Not Modified: the server's answer when a saved copy is still current; it has no body.
Cache-Control: the header that tells browsers how to cache:no-cache(ask first),max-age(reuse for this long).
Go deeper
- MDN: how browsers work (opens in a new tab), and HTTP caching (opens in a new tab).
- web.dev: understand the critical path (opens in a new tab).
- Try: change
slow.jsinblocking.htmltoasync. When does the page paint now, and when does the menu appear? - Try: change the server's header to
Cache-Control: max-age=60and reload twice. Which requests disappear from the server's log?