HTML parsing and the preload scanner
The browser reads ahead in the HTML to fetch files early. See six real loads of one page, and what hides its main picture from the preload scanner.
- 01Explain the two readersSay how the parser reads HTML a piece at a time, why a plain script stops it, and how the preload scanner skims ahead to fetch files early.
- 02Spot hidden filesRecognize files the scanner can't see: CSS backgrounds, tags added by scripts, lazy loading with data-src, and pages rendered on the client.
- 03Check and fix discoveryRead when each file was asked for and what asked for it, then move its name into the HTML or preload it.
Two readers of the same HTML
In the last lesson, you followed one visit to completefrontend.com, from pressing Enter to the first paint. Near the end, the HTML started to arrive, and the browser began to read it. This lesson looks closely at that reading.
An HTML page is more than its own text. It names other files the page needs: a stylesheet, scripts, pictures. Each one is another request to the server, and each request takes time. So when does the browser ask for those files? Does it wait until its reading reaches each tag? Or does it ask sooner?
To find out, the course built a tiny page: the mint tea page at Leaf and Kettle, the tea shop from the first lessons. Its HTML is under 1 KB. It names a stylesheet and one big picture, a glass of mint tea of about 100 KB. The course loaded it in Chrome with a 150 ms round trip added to every request. A round trip is a message to the server and its reply back.
Here is the plain version, with the picture written in the HTML:
// The mint tea page, with the picture written in its HTML.// Every time is in milliseconds (ms) after the visit started.const load = { htmlArrived: 153, pictureAsked: 160, picturePainted: 392 }; console.log(`The last byte of the HTML arrived at ${load.htmlArrived} ms.`);console.log(`The browser asked for the picture at ${load.pictureAsked} ms.`);console.log(`The picture appeared on the screen at ${load.picturePainted} ms.`);- Lines 1 and 2 are comments. Every time is counted in milliseconds (ms) from the moment the visit started.
- Line 3 holds three times Chrome recorded.
- Lines 5 and 6 print
The last byte of the HTML arrived at 153 ms.andThe browser asked for the picture at 160 ms. - Line 7 prints
The picture appeared on the screen at 392 ms.
The browser asked for the picture just 7 ms after the HTML arrived. The moment the picture appears is this page's LCP, Largest Contentful Paint: the moment the biggest thing on the screen is painted. Here, that's the picture.
Then the course wrote the same page five more ways. The picture, the network and the server stayed the same. Each version changed one thing: where the picture's name is written, or what comes before it. Here is when each version asked for the picture:
Three versions asked for the picture right after the HTML arrived. The other three asked 150 ms or more later. The version rendered on the client waited until 475 ms. Same picture, same network. Why?
The answer is that two readers go through the same HTML at the same time:
- The HTML parser reads the HTML in order, from top to bottom. As it reads, it builds the page's tree of elements, the DOM. Some scripts make it stop and wait.
- The preload scanner skims ahead in the same HTML. It looks only for the names of files. When it finds one, the browser starts fetching that file at once.
A cook works through a recipe one step at a time: chop, stir, bake. While the cook reads the first step, a helper skims the whole page. The helper fetches every ingredient the recipe names and puts it on the counter. When the cook reaches the step that needs the mint, the mint is already there.
- In real life: The cook, following the steps in order
- In JavaScript: The HTML parser, building the DOM tag by tag
- In real life: The helper, skimming the whole recipe
- In JavaScript: The preload scanner
- In real life: An ingredient named in the recipe
- In JavaScript: A file named in the HTML, like
<img src="mint.svg"> - In real life: The mint, already on the counter
- In JavaScript: The picture, downloaded before the parser reaches its tag
The preload scanner can only fetch files whose names are written in the HTML. A file named anywhere else is found late: in the CSS, by a script, or in data. So if a file matters for the first view, write its name in the HTML.
The rest of the lesson takes the six versions one at a time. First: how the parser reads, and what stops it. The JavaScript course tells the same story from the script's side, in From HTML to running script.
Incremental HTML parsing
The parser doesn't wait for the whole file. It starts with the first bytes that arrive, builds what it can, and goes on as more come in. That's what incremental means: a piece at a time. It's like reading a long letter page by page, as each page is handed to you. On a big page, the browser can paint the top while the bottom is still downloading.
A script that makes the parser stop
One kind of tag makes the parser stop: a <script src> without async or defer. The parser can't read past it until the script has downloaded and run.
Halfway down the recipe, the cook finds a card: "Before you go on, make the sauce on the back." The cook can't skip it, because the sauce might change the next steps. So the cook stops, follows the card, and only then reads on. If the sauce needs something from the shop, the cook waits for the shop too.
- In real life: The "do this first" card
- In JavaScript: A
<script src>withoutasyncordefer - In real life: The sauce might change the next steps
- In JavaScript: The script can change the page before the parser reads on
- In real life: Waiting for the shop
- In JavaScript: Waiting for the script to download
- In real life: Reading on once the sauce is made
- In JavaScript: The parser going on once the script has run
Why can't the parser skip the script? Because a script can change the page while it's being read. It can even write new HTML into the spot where it sits, with document.write(). So the parser has to let it run first. A script that stops the parser like this is called parser-blocking.
The words async and defer let the parser go on while the script downloads. The next lesson, Render-blocking and parser-blocking resources, covers them.
The course tried a parser-blocking script on the tea page. It added widget.js to the head, a stand-in for a slow third-party script. A third party is another company's code that a page loads, like a chat box or an ad. The course made the server take a whole second to send it. Here is the top of that page:
<head> <link rel="stylesheet" href="styles.css"> <script src="widget.js"></script></head><body> <header>Leaf and Kettle</header> <main id="tea"> <img id="hero" src="mint.svg" alt="A glass of mint tea" width="360" height="270">- Line 2 names the stylesheet.
- Line 3 is the script. It has no
asyncordefer, so the parser stops here. - Line 8 is the picture, five lines further down.
How much did the script cost? Here are two recorded loads, one with the script and one without it:
// The tea page with widget.js in its head, and the same page without it.const withTheScript = { scriptArrived: 1164, picturePainted: 1192 };const withoutIt = { picturePainted: 392 }; console.log(`The parser waited for widget.js until ${withTheScript.scriptArrived} ms.`);console.log(`The picture appeared ${withTheScript.picturePainted - withoutIt.picturePainted} ms later than without the script.`);- Line 2 holds two times from the page with the script: when
widget.jsarrived, and when the picture appeared. - Line 3 holds when the picture appeared on the page without it.
- Line 5 prints
The parser waited for widget.js until 1164 ms. - Line 6 subtracts one paint time from the other. It prints
The picture appeared 800 ms later than without the script.
The parser sat at line 3 for about a second. Nothing at all was painted until the script had arrived. One slow script in the head held back the whole page.
But what about the picture on line 8? Did the browser wait a whole second to ask for it too?
What the preload scanner finds
It didn't wait. Here is the picture's download on the same page:
// The page with widget.js again: the picture's download.const picture = { askedAt: 160, arrivedAt: 357 };const scriptArrived = 1164; console.log(`The picture was asked for at ${picture.askedAt} ms.`);console.log(`It had fully arrived at ${picture.arrivedAt} ms.`);console.log(`That was ${scriptArrived - picture.arrivedAt} ms before widget.js arrived and let the parser go on.`);- Line 2 holds when the browser asked for the picture, and when its last byte arrived.
- Line 3 holds when
widget.jsarrived. - Lines 5 and 6 print
The picture was asked for at 160 ms.andIt had fully arrived at 357 ms. - Line 7 prints
That was 807 ms before widget.js arrived and let the parser go on.
160 ms is exactly when the plain page asked for its picture. The parser was stuck at the script. Yet something read past it, found the <img> tag, and asked for the picture. That something was the preload scanner: the helper who skims the recipe while the cook is busy.
The HTML standard calls this speculative parsing. Speculative means done ahead of time, on a guess. The browser fetches the files now, guessing that the page will still need them once the script has run. If the script changes the page so that a file isn't needed after all, that download is wasted.
Jeremy Wagner's web.dev article calls the preload scanner "a secondary HTML parser". It doesn't build anything. It only looks for file names. Read each row of this table as: when the HTML has this tag, the scanner does this.
| When the HTML has | What the scanner does |
|---|---|
| <img src> and <img srcset> | It fetches the picture the tag names. |
| <script src>, with or without async and defer | It fetches the script, even while another script holds up the parser. |
| <link rel=stylesheet> | It fetches the stylesheet. |
| <link rel=preload> | It fetches whatever the tag names, such as a font or a picture. |
On the page with widget.js, the scanner found all three files at once. The stylesheet, the script and the picture were all asked for at 160 ms.
The standard leaves the exact list of tags to each browser. But one rule matters most. The preload scanner only reads HTML. It doesn't read CSS, and it doesn't run JavaScript.
Remember what the script cost, though. The scanner saved the picture's download. It couldn't save the paint, because only the parser builds the page, and the parser was still waiting. So a parser-blocking script costs time even when the scanner has fetched everything else.
Client-side rendering and late discovery
The last two versions are built a different way. The server sends an almost empty page with one script. The script runs in the browser, asks the server for the data, and builds the page from it. This is called client-side rendering: the page is built on the client, the browser, not on the server. Apps built with React or Vue can work this way.
Here is the tea page's script, app.js:
// Ask the server for this week's tea, then show it with its picture.fetch("tea.json") .then((response) => response.json()) .then((tea) => { document.querySelector("#tea").innerHTML = ` <img id="hero" src="${tea.picture}" alt="${tea.alt}" width="360" height="270"> <h1>${tea.name}</h1> <p>${tea.about}</p>`; });- Line 2 asks the server for
tea.json, the data about this week's tea. - Line 3 reads the answer as JSON.
- Lines 5 to 8 write the tea into the page. Line 6 is the
<img>tag. Itssrccomes from the data,tea.picture.
So the picture's name is three steps away from the HTML. The HTML names app.js. Then app.js asks for tea.json. Then tea.json names mint.svg.
In a treasure hunt, the first clue tells you where to find the second. The second tells you where to find the third. You can't skip ahead, because you don't know where the next clue is until you've read the one before. The prize is always found last.
- In real life: The first clue
- In JavaScript: The HTML, which names
app.js - In real life: The second clue
- In JavaScript:
app.js, which asks fortea.json - In real life: The third clue
- In JavaScript:
tea.json, which namesmint.svg - In real life: The prize, found last
- In JavaScript: The picture
Here is the hunt, recorded:
// The page rendered on the client. Each file waits for the one before.const askedAt = { "app.js": 161, "tea.json": 321, "mint.svg": 475 }; for (const [file, time] of Object.entries(askedAt)) { console.log(`The browser asked for ${file} at ${time} ms.`);}- Line 2 holds when the browser asked for each file.
- Lines 4 to 6 print one line for each file.
- They print
The browser asked for app.js at 161 ms.andThe browser asked for tea.json at 321 ms. - Last comes
The browser asked for mint.svg at 475 ms.
Each request started only when the one before it had arrived. Each step cost about one round trip, 150 ms. The picture was asked for at 475 ms and appeared at 692 ms. From the HTML, it appeared at 392 ms.
A preload names the picture early
Can the page tell the browser about the picture sooner? Yes. A preload is a tag in the HTML that says: "fetch this file now, the page will need it soon". The preload scanner reads it like any other tag. The last version adds one line to the head:
<link rel="stylesheet" href="styles.css"><link rel="preload" href="mint.svg" as="image"><script src="app.js" defer></script>- Line 1 names the stylesheet, as before.
- Line 2 is the preload.
rel="preload"asks for an early fetch.hrefnames the file.as="image"says what kind of file it is, so the browser fetches it the right way. - Line 3 names
app.js, as before.
It's like writing the saffron into the recipe itself, so the helper sees it. Here is what the preload changed:
// The same page, with a preload for the picture in its head.const preloaded = { pictureAsked: 164, dataArrived: 482, picturePainted: 492 }; console.log(`The picture was asked for at ${preloaded.pictureAsked} ms, right after the HTML.`);console.log(`Its <img> tag was only made once tea.json arrived, at ${preloaded.dataArrived} ms.`);console.log(`The picture appeared on the screen at ${preloaded.picturePainted} ms.`);- Line 2 holds three recorded times.
- Line 4 prints
The picture was asked for at 164 ms, right after the HTML. - Line 5 prints
Its <img> tag was only made once tea.json arrived, at 482 ms. - Line 6 prints
The picture appeared on the screen at 492 ms.
The preload saved 200 ms: the picture appeared at 492 ms instead of 692 ms. But the picture in the HTML still won, at 392 ms. The preload fixed when the picture was found. It couldn't fix when the page was built: the <img> tag still waited for the script and the data. HTML sent from the server, with the picture already in it, fixes both.
web.dev adds a warning. Preloads compete with the page's other downloads. Use them for the few files the scanner can't find, and test each one.
Checking discovery in a waterfall
How do you check your own page? Use a waterfall: a chart with one bar per file. Each bar starts when the browser asked for the file, and ends when its last byte arrived. For each important file, ask two questions:
- When did its bar start: right after the HTML, or later?
- What asked for it?
The second answer comes from Resource Timing, the browser's record of every file a page loaded. Its initiatorType names what asked for each file. Read each row of this table as: when a file shows this initiatorType, this is what asked for it.
| When initiatorType says | What asked for the file |
|---|---|
| link | A <link> tag asked for it: a stylesheet or a preload. |
| script | A <script src> tag asked for it. |
| img | An <img> tag asked for it, whether it was in the HTML or added later. |
| css | A stylesheet asked for it, with url(), such as a background picture. |
| fetch | A script asked for it with fetch(). |
The playground below holds all six versions. Pick one, and watch where the mint.svg bar starts. Then read the word after it.
160 msimg356 ms392 msIn the HTML. The browser asked for the picture at 160 ms, and the picture appeared at 392 ms. The <img> tag is in the HTML. So the browser asked for the picture as soon as the HTML arrived, together with the stylesheet.
A late bar marked css or fetch is a file the scanner couldn't see. A late bar marked img needs a closer look. In the version where a script added the picture, the bar starts late and still says img, because a script made the tag.
Now look at this page's own files. Press Look:
const files = performance.getEntriesByType("resource"); const byInitiator = Object.groupBy(files, (file) => file.initiatorType);for (const [initiator, list] of Object.entries(byInitiator)) { const first = Math.min(...list.map((file) => file.startTime)); console.log(initiator, list.length, "files, the first requested at", Math.round(first), "ms");}Nothing read yet.
Press Look. The panel groups this page's files by what asked for them, Resource Timing's initiatorType. It shows when each group's first file was asked for.
In the panel's code, line 1 gets every file the page loaded. Line 3 groups the files by initiatorType. Line 5 finds the earliest request in each group.
Found early, or found late?
For each tag, ask one question: is the file's name written in the HTML? Then sort it:
- <img src="hero.jpg"> in the HTML
- <script src="app.js"> in the head
- <script src="chat.js" async> in the HTML
- <link rel="preload" href="hero.jpg" as="image">
- A main picture set with background-image in the CSS
- <img data-src="hero.jpg"> swapped in by a lazy-loading script
- A picture named in JSON that the app's script draws
- A script added with document.createElement("script")
Sort each file by whether the preload scanner can find it in the HTML.
Common mix-ups
- "The preload scanner makes blocking scripts harmless." No. It fetches the other files early, but the page still waits for the script. The recorded one held the picture back by 800 ms.
- "The scanner reads the CSS too." No. It only reads HTML. A background picture waits for its stylesheet.
- "async scripts are hidden from the scanner." No. An
asyncscript written in the HTML is found early.asyncchanges when it runs. Scripts added by other scripts are the hidden ones. - "A preload makes client-side rendering as fast as HTML." No. It fixes when the picture is found, not when the page is built. In the recordings, the picture appeared at 492 ms with the preload, and at 392 ms from the HTML.
- "Preload everything." No. Preloads compete with the page's other downloads. Use them for the few files the scanner can't find, and test each one.
- "Lazy-load every picture." No. Lazy loading with a script hides the picture from the scanner. A picture on screen from the start, often the LCP, should use a plain
src.
Practice exercises
Answer first, then use the hints.
The third party gets a second slower. What does the second line print?
// The tea page with widget.js in its head, and the same page without it.
const withTheScript = { scriptArrived: 2164, picturePainted: 2192 };
const withoutIt = { picturePainted: 392 };
console.log(`The parser waited for widget.js until ${withTheScript.scriptArrived} ms.`);
console.log(`The picture appeared ${withTheScript.picturePainted - withoutIt.picturePainted} ms later than without the script.`);The picture appeared 1800 ms later than without the script. The script took a second longer, so the paint did too. The picture itself still arrived early: the preload scanner fetched it while the parser waited.
The stylesheet arrives 100 ms later, so the browser asks for the background picture at 418 ms. What does the second line print?
// The picture as a CSS background. Its name is only in styles.css.
const background = { stylesArrived: 417, pictureAsked: 418 };
const inTheHtml = { pictureAsked: 160 };
console.log(`styles.css arrived at ${background.stylesArrived} ms.`);
console.log(`The picture was asked for at ${background.pictureAsked} ms, ${background.pictureAsked - inTheHtml.pictureAsked} ms later than from the HTML.`);The picture was asked for at 418 ms, 258 ms later than from the HTML. Whatever delays the CSS delays every picture named in it.
A page's main picture is written as <img data-src="hero.jpg">, and a lazy-loading script sets src. Which finds the picture: the preload scanner or the script?
The script: the lazy loader finds the picture once it has downloaded and run. A picture on screen from the start should use a plain src, so the scanner can fetch it with the HTML.
In the recorded page that was rendered on the client with a preload, the picture arrived before it could be shown. How many milliseconds passed between the picture arriving and the picture appearing on the screen?
130 ms. The picture arrived at 362 ms and sat ready while app.js and tea.json made its tag. It appeared at 492 ms. A preload fixes when a file is found, not when the page is built.
Which column of DevTools' Network panel shows what asked for each file?
Initiator. It names what asked for each file: the parser, a script, or a stylesheet. Hovering it shows the chain of requests that led to the file.
A shop's main picture is a CSS background: .hero { background: url(hero.jpg) }. What one tag in the head lets the preload scanner find hero.jpg with the HTML?
<link rel="preload" href="hero.jpg" as="image">. The scanner reads it and asks for the picture with the HTML. Writing the picture as an <img> tag in the HTML would need no preload at all.
Check your understanding
For each question, ask where the file's name is written: in the HTML, in the CSS, or in a script.
Question 1 of 8What does the second line of the CSS background example print?
Read the code, then predict// The picture as a CSS background. Its name is only in styles.css. const background = { stylesArrived: 317, pictureAsked: 318 }; const inTheHtml = { pictureAsked: 160 }; console.log(`styles.css arrived at ${background.stylesArrived} ms.`); console.log(`The picture was asked for at ${background.pictureAsked} ms, ${background.pictureAsked - inTheHtml.pictureAsked} ms later than from the HTML.`);Choose an answer to see the explanation.
Question 2 of 8What does the preload scanner read?
Choose an answer to see the explanation.
Question 3 of 8Why does a plain <script src> in the head stop the parser?
Choose an answer to see the explanation.
Question 4 of 8In the recorded page behind a blocking script, why had the picture arrived long before the parser reached its tag?
Choose an answer to see the explanation.
Question 5 of 8Why can't the preload scanner find a picture that a script adds to the page?
Choose an answer to see the explanation.
Question 6 of 8What does the third line of the client-rendered chain example print?
Read the code, then predict// The page rendered on the client. Each file waits for the one before. const askedAt = { "app.js": 161, "tea.json": 321, "mint.svg": 475 }; for (const [file, time] of Object.entries(askedAt)) { console.log(`The browser asked for ${file} at ${time} ms.`); }Choose an answer to see the explanation.
Question 7 of 8In the recordings, which put the picture on the screen sooner: the client-rendered page with a preload, or the picture written in the HTML?
Choose an answer to see the explanation.
Question 8 of 8What initiatorType does Resource Timing give a picture set with background-image in a stylesheet?
Choose an answer to see the explanation.
Key takeaways
- The parser reads the HTML a piece at a time, as it arrives, and builds the page.
- A script tag without async or defer stops the parser until the script has downloaded and run.
- The preload scanner skims ahead in the same HTML and fetches the files it names, even while the parser waits.
- It only reads HTML. A file named in CSS, added by a script, or named in data is found late.
- A preload fixes when a file is found. HTML from the server, with the file in it, also fixes when it's shown.
- In a waterfall, check when each important file's bar starts, and what asked for it.
Remember the one-liner.
If its name isn't in the HTML, the browser finds it late.
Sources
The facts on this page come from these standards and docs:
- Speculative HTML parsing, HTML Living Standard, WHATWG.
- Don't fight the browser preload scanner, Jeremy Wagner, web.dev.
- html_preload_scanner.cc: what Chrome's preload scanner fetches, Chromium source.
- rel=preload, MDN.
- PerformanceResourceTiming: initiatorType, MDN.
Coming next: Render-blocking and parser-blocking resources. Here, one script stopped the parser. Next: why CSS holds back the first paint, and what async, defer and type="module" change.