From HTML to running script
Learn how browsers tokenize HTML, build a document, discover scripts, fetch them, and choose when each kind may execute.
- 01Follow the parserExplain how HTML becomes tokens and then elements, and why a plain script makes the parser wait.
- 02Choose how a script loadsCompare classic, async, defer, and module scripts: when each one downloads, when it runs, and in what order.
- 03Explain loading surprisesExplain how the preload scanner finds files early, why document.write breaks easily, and how the lesson's model differs from real download times.
HTML arrives before JavaScript runs
A web page arrives as HTML text, a little at a time. The browser doesn't wait for all of it. It starts reading as soon as the first part arrives.
The part of the browser that reads HTML is called the HTML parser. Reading the HTML is called parsing. The parser turns the text into page elements, such as paragraphs and buttons.
A <script> tag can sit anywhere in that HTML. So a script may run before the whole page exists.
Script loading is everything that happens to a script tag, in this order:
- The browser finds the tag.
- It downloads the script's file, if it has one.
- It decides when the script may run.
- It runs the script.
- It carries on reading the HTML.
The tag itself decides when its code runs. These things change the timing:
- where the tag sits in the HTML;
src, which names a separate file to download;- the words
asyncanddeferon the tag; type="module"on the tag.
You'll meet each one in this lesson. For every script, keep three questions apart:
- Where has the parser got to?
- Has the script's file finished downloading?
- Is the script allowed to run yet?
These are different questions, even when the answers seem to come at the same moment.
This lesson continues from What happens when you navigate.
The HTML tokenizer and tree builder
The parser does its work in two parts:
- The tokenizer reads the HTML one character at a time. It cuts the text into pieces called tokens. A token is a start tag like
<p>, some text, or an end tag like</p>. - The tree builder takes the tokens one by one. It turns them into elements and puts each element inside its parent. The result is the document tree, also called the DOM.
Both parts work while the HTML is still arriving. The browser can make the first paragraph before the last part of the page arrives. That is why a script can find an element that comes earlier in the HTML.
The code below is a small model of these two parts. It is a teaching model written for this lesson, not the browser's real code. The lesson's setup code defines tokenizeTinyHtml and buildTinyTree for you.
const markup = "<p>Hi <b>there</b></p>";
const tokens = tokenizeTinyHtml(markup);
const tree = buildTinyTree(tokens);
console.log(tokens.map((token) => token.type).join(","));
console.log(tree.children[0].children.map((node) => node.name).join(","));Line 1 stores a short piece of HTML. Line 2 cuts it into tokens, in the order they appear. Line 3 gives those tokens to the tree builder. Line 5 prints the kind of each token: start-tag,text,start-tag,text,end-tag,end-tag.
Line 6 prints "Hi ",b. These are the two things directly inside the paragraph: first the text, then the bold element b. The word there is inside b. So it is not directly inside the paragraph.
Step through a small model of the tokenizer and the tree builder. Every value comes from real calls to this lesson's code. It is not the browser's own code.
script
const tokens = tokenizeTinyHtml(markup);const tree = buildTinyTree(tokens); console.log(tokens.map((token) => token.type).join(","));console.log(tree.children[0].children.map((node) => node.name).join(","));Browsers also fix broken HTML. In the code below, the first paragraph has no closing </p> tag. The model follows one real browser rule: a new <p> closes the paragraph that is still open.
const tree = buildRecoveringTree("<p>Tea<p>Cake");
const paragraphs = tree.children.filter((node) => node.name === "p");
console.log(paragraphs.length);
console.log(paragraphs.map((node) => node.children[0].name).join(","));Line 1 leaves out the first closing tag on purpose. Line 2 picks out the paragraphs from the tree. Line 4 prints 2, and line 5 prints "Tea","Cake". Real browsers have many more rules like this one. The model shows only one.
Scripts that make the parser wait
Two words first:
- A classic script is a plain
<script>tag, with notype="module". - An inline script has its code written inside the tag, with no
srcfile.
An inline classic script runs the moment the parser reaches it. It can find elements the parser has already built. It can't find elements that come later in the HTML.
<p>Hello</p><script>console.log(document.querySelectorAll("p").length)</script><p>World</p>The parser reads the first paragraph before the script. The script calls querySelectorAll("p"), which finds every paragraph on the page so far. Only the first paragraph exists, so it prints 1. Then the parser carries on and builds the second paragraph.
1.A classic script with a src file follows the same rule, plus a download. The parser stops while the file downloads. Then it stops again while the script runs. This is called a parser-blocking script, because it blocks (stops) the parser.
A stylesheet can make the wait longer. The script might ask for the page's styles, so the browser may wait for the stylesheet before it runs the script.
You read a recipe aloud. You reach the line "go and buy milk". Everything stops until the milk arrives. Meanwhile, a friend can read ahead and start buying the other things early.
With async, you use the milk as soon as it arrives. With defer, you use it only after the whole recipe is read, in the order the recipe lists things.
- In real life: You reach the line "go and buy milk"
- In JavaScript: The parser reaches a classic script
- In real life: Reading stops until the milk arrives
- In JavaScript: Parsing waits for the download and the run
- In real life: A friend reads ahead and buys other things early
- In JavaScript: The preload scanner starts later downloads early (next section)
- In real life: Use the milk as soon as it arrives, or after the whole recipe is read
- In JavaScript:
async, ordefer
The preload scanner looks ahead
While the parser is stopped, it can't see the tags further down the page. So the browser has a helper called the preload scanner. It skims ahead through the HTML text. When it sees a file address, it starts downloading that file early, before the parser gets there.
The preload scanner doesn't build elements. It never runs the page's JavaScript. It can only find addresses written plainly in the HTML, such as a stylesheet, an image, or a script's src.
<script src="/checkout.js" defer></script>The address /checkout.js is written in the HTML, so the browser can start downloading it early. The defer on the tag still makes the script wait to run until all the HTML is read.
const html = '<script src="/app.js" defer></script>';
const injectedLater = 'document.createElement("script")';
console.log(html.includes("src=") ? "found early" : "not found");
console.log(injectedLater.includes("src=") ? "found early" : "created later");Line 1 is HTML with a src in it, so line 4 prints found early. Line 2 stands for JavaScript that makes a script tag later, with document.createElement("script"). That text has no address in it, so line 5 prints created later.
The preload scanner can't find a script that doesn't exist yet. That script only appears when the JavaScript that makes it runs.
async, defer, and module scripts
Three choices change when a script runs. With all three, the file downloads while the parser keeps reading.
async runs the script as soon as its file has downloaded. This suits a script that no other script depends on. Two async scripts don't keep their written order. A later tag runs first if its file arrives first.
defer runs the script after the parser has read all the HTML. Deferred classic scripts run in the order they are written. So they suit files that must run in order, such as a cart file and then a checkout file.
async script runs as soon as its file arrives. A defer script waits until all the HTML is read.A module script has type="module". It can use import to load other files. The browser downloads the module and every file it imports while the parser keeps reading. Without async, a module waits like defer. With async, it runs as soon as it and all its imports are ready.
An import map is a <script type="importmap"> tag. It tells the browser which file address to use for a short module name, such as "cart". Put it before the modules that use those names. It doesn't run any code itself.
const tags = [
'<script src="/app.js"></script>',
'<script src="/stats.js" async></script>',
'<script src="/boot.js" defer></script>',
'<script type="module" src="/main.js"></script>',
];
console.log(tags.length);
console.log("async runs when ready; defer waits for parsing");Lines 1 to 6 make a list of four script tags:
- Line 2 is a plain classic script.
- Line 3 is a classic script with
async. - Line 4 is a classic script with
defer. - Line 5 is a module script.
Line 8 prints 4, the number of tags. Line 9 prints the main difference: async runs when ready, and defer waits for parsing.
Each row of the table below is one kind of script tag. Read it as: "this kind of tag treats the parser like this, and this decides its order."
| Kind of script tag | What happens to parsing, and when it runs | What decides the order |
|---|---|---|
Classic script with src | Parsing stops while the file downloads and runs. It runs at its spot in the HTML. | Its spot in the HTML. |
Classic script with async | The file downloads while parsing goes on. It runs as soon as the file is ready. | Which file finishes downloading first. |
Classic script with defer | The file downloads while parsing goes on. It runs after parsing ends. | The written order of the tags. |
Module without async | The module and its imports download while parsing goes on. It runs after parsing ends. | The written order of the tags. |
Module with async | The module and its imports download while parsing goes on. It runs as soon as they are all ready. | Which one is ready first, not the written order. |
Script execution order
No single rule decides the order scripts run in. Three things decide it together:
- where the parser has got to;
- which files have finished downloading;
- whether a script may run before parsing ends.
Real download times change from one visit to the next. So this lesson uses a small model where you set the times yourself. Each script has a kind and a downloadDoneAt number: the time its file finishes downloading.
In the model, parsing ends at time 3. Deferred scripts and modules run together, once parsing has ended and all of their files have downloaded. This is a teaching model, not the browser's real scheduler.
const scripts = [
{ name: "inline settings", kind: "inline", downloadDoneAt: 0 },
{ name: "checkout", kind: "async", downloadDoneAt: 2 },
{ name: "app", kind: "defer", downloadDoneAt: 5 },
{ name: "catalog", kind: "module", downloadDoneAt: 1 },
];
console.log(executionOrder(scripts).join(" -> "));Lines 2 to 5 list four scripts, in the order they appear in the HTML:
- Line 2 is inline, so it runs in place.
- Line 3 is async. Its file is ready at time 2.
- Line 4 is deferred. Its file is ready at time 5.
- Line 5 is a module. Its file is ready at time 1, but it still waits for parsing.
Line 8 prints inline settings -> checkout -> app -> catalog. The inline script runs first, in place. The async script runs at time 2, as soon as it is ready. The deferred script and the module wait for parsing to end. Then they run in their written order.
app and catalog wait until parsing ends, then run in the order they were written, even though catalog was ready first.Step through a small model of the order scripts run in. Real download times change from one visit to the next. So in this model, you set the times yourself.
script
{ name: "inline settings", kind: "inline", downloadDoneAt: 0 }, { name: "checkout", kind: "async", downloadDoneAt: 2 }, { name: "app", kind: "defer", downloadDoneAt: 5 }, { name: "catalog", kind: "module", downloadDoneAt: 1 },]; console.log(executionOrder(scripts).join(" -> "));const scripts = [ { name: "inline settings", kind: "inline", downloadDoneAt: 0 }, { name: "checkout", kind: "async", downloadDoneAt: 2 }, { name: "app", kind: "defer", downloadDoneAt: 5 }, { name: "catalog", kind: "module", downloadDoneAt: 1 },]; console.log(executionOrder(scripts).join(" -> "));inlineFile ready at time 0
asyncFile ready at time 2
deferFile ready at time 5
moduleFile ready at time 1
The scripts now run in this order: inline settings -> checkout -> app -> catalog. Only checkout, the async script, moves when its time changes. The deferred script (app) and the module (catalog) always run in their written order.
In the playground, change the checkout download time from 2 to 9, then press Reset. At 9, checkout moves after the deferred script and the module. Those two stay in the same order. On a real network, download times change, so your app should never depend on the order of async scripts.
document.write writes into the HTML being read
document.write() adds HTML text straight into the HTML the parser is reading. Say an inline script calls it while the page is being read. Then the new HTML goes in at that exact spot. The parser reads it before the rest of the page.
Old pages used this to add ads or small pieces of HTML. But it breaks easily. The result depends on whether the browser is still reading the original page.
document.write("<p>Added while parsing</p>");
console.log("write was part of the current document stream");This code only makes sense inside an inline script, while the page is being read. It isn't meant for the editor after the page has loaded. Line 1 adds a paragraph at the parser's current spot. Line 2 logs a note about that.
After the page has loaded, the parser is finished. Then document.write() starts a new, empty page that replaces the old one. In new code, add elements with DOM methods instead:
document.createElement("p")makes a new element.textContentsets its text.appendputs it inside a parent element you choose.
These steps work the same at any time, whether or not the parser is still running.
Fix a slow page's script tags
Use a parser-blocking classic script only when the script really must run at its exact spot in the HTML. Most startup code should use modules or deferred scripts. Then the parser can keep building the page while the files download.
Picture a shop page. The checkout code needs the cart code to run first. The two plain classic tags below stop the parser twice. Each download must finish before the parser can go on.
<script src="/cart.js"></script>
<script src="/checkout.js"></script>Line 1 stops the parser until cart.js has downloaded and run. Then line 2 stops it again for checkout.js. The order is correct, but the parser sits idle during each download.
<script src="/cart.js" defer></script>
<script src="/checkout.js" defer></script>
<script src="/analytics.js" async></script>Lines 1 and 2 now download while the parser keeps reading. They run after parsing, in order: cart, then checkout.
Line 3 loads analytics. Analytics code counts page visits, and no other script needs it. So async lets it run whenever its file is ready.
This change doesn't make a slow server faster. It removes waits the parser didn't need, and it shows clearly which script needs which. Measure the page after you change the tags. A tag only changes when a script runs. It doesn't make any file smaller.
- An inline classic script
- A classic script with
srcand nothing else - A classic script with
srcandasync - A module script with
async - A classic script with
srcanddefer - A module script without
async
Put each card under the rule for when it runs, then read why.
Common mistakes about loading scripts
async changes when a script runs, not how fast it downloads. A file that downloads quickly runs sooner. But the word async doesn't make the file smaller or the server faster.
The preload scanner finds files to download. It doesn't run JavaScript. Deferred classic scripts keep their written order; async scripts don't. Module scripts without async wait for parsing and for every file they import.
- "Async is always faster." It only changes when the script may run.
- "Defer is async with a new name." Defer waits for parsing and keeps the written order.
- "The preload scanner runs code." It only finds files whose addresses are written in the HTML.
- "document.write adds elements like other DOM methods." It writes into the HTML being read, so its result depends on timing.
Each row of the table below is a word that is easy to mix up. Read it as: "this word means this, and it does not mean that."
| Word | What it means | What it does not mean |
|---|---|---|
async | The script runs as soon as its file is ready, maybe before parsing ends. | A way to keep scripts in their written order. |
defer | The script runs after parsing ends. Deferred classic scripts keep their written order. | A way to run a script the moment its file arrives. |
| Module | A module script waits like defer, unless it has async. | A script whose timing changes when you add defer. |
| Preload scanner | A helper that reads ahead in the HTML and starts downloading files early. | A second parser that builds elements or runs JavaScript. |
Practice exercises
The starter code stands for the moment a script runs. One paragraph exists already. The next one hasn't been read yet. The list before holds the paragraphs that exist. Type what the code prints, with no extra words.
const before = ["Hello"];
console.log(before.length);console.log(before.length); // 1Only one paragraph exists when the script runs, so it prints 1.
The tokenizer model reads <p>Hi</p> from left to right. Type the kinds of the first two tokens, separated by a comma.
tokenizeTinyHtml("<p>Hi</p>").slice(0, 2);The first two tokens are start-tag for <p>, then text for Hi.
Two scripts use async. In this model, the later tag finishes downloading first. Type the order the model prints, separated by a comma. Notice that the written order doesn't decide which async script runs first.
const scripts = [
{ name: "first", kind: "async", downloadDoneAt: 3 },
{ name: "second", kind: "async", downloadDoneAt: 1 },
];
console.log(executionOrder(scripts).join(","));console.log(executionOrder(scripts).join(",")); // second,firstThe second async script is ready at 1, before the first one at 3, so it runs first.
The module finishes downloading before the deferred classic script. Both still wait until parsing ends. Type the order the model prints, separated by a comma. Use the order of the tags, not the download times.
const scripts = [
{ name: "app", kind: "defer", downloadDoneAt: 4 },
{ name: "catalog", kind: "module", downloadDoneAt: 1 },
];
console.log(executionOrder(scripts).join(","));console.log(executionOrder(scripts).join(",")); // app,catalogBoth scripts run after parsing, in the order their tags are written.
Your page sends a "page viewed" message with an analytics file. Neither the cart code nor the checkout code reads anything from it. Type one word for how its tag should load. It shouldn't wait for other scripts, and they shouldn't wait for it.
<script src="/analytics.js" async></script>Use async for analytics. No startup script needs it, so its order doesn't matter.
A shop page loads the cart code first. Then the checkout code uses the cart code. Fix the tags so they don't stop the parser. Type one word for how both tags should load, then compare your answer with the shop example above.
<script src="/cart.js" defer></script>
<script src="/checkout.js" defer></script>Use defer, or modules without async. Checkout needs cart to run first, and the parser can keep reading while both download.
Check your understanding
For each question, keep three things apart: where the parser is, which files are ready, and the written order. The questions use the same small models as the lesson.
Question 1 of 7Why does the first parser example print
1?Choose an answer to see the explanation.
Question 2 of 7What does this print?
Read the code, then predictconst scripts = [{ name: "a", kind: "async", downloadDoneAt: 4 }, { name: "b", kind: "async", downloadDoneAt: 1 }]; console.log(executionOrder(scripts).join(","));Choose an answer to see the explanation.
Question 3 of 7What does a preload scanner do?
Choose an answer to see the explanation.
Question 4 of 7Which statement describes
defer?Choose an answer to see the explanation.
Question 5 of 7How does a module script behave when it lacks
async?Choose an answer to see the explanation.
Question 6 of 7Why is
document.write()discouraged?Choose an answer to see the explanation.
Question 7 of 7What does this deferred tag plan print first?
Read the code, then predictconst plan = describeScriptTag({ src: "/checkout.js", defer: true }); console.log(plan.executionGate);Choose an answer to see the explanation.
Key takeaways
- The tokenizer cuts HTML into tokens. The tree builder turns them into elements while the HTML is still arriving.
- A classic script stops the parser at its spot in the HTML.
- The preload scanner finds file addresses written in the HTML and starts downloading them early. It never runs code.
asyncruns a script when its file is ready.deferand modules withoutasyncwait for parsing.- Before you change a script tag, work out which scripts need which.
- Avoid
document.writein new code. Its result depends on whether the parser is still running.
Remember the one-liner.
A script tag decides when its code runs, not only what code runs.
Coming next: The rendering pipeline.