From HTML to running script
Learn how browsers tokenize HTML, build a document, discover scripts, fetch them, and choose when each kind may execute.
- 01Trace parser workExplain how tokens become a tree and why an ordinary script can stop parser progress.
- 02Choose a script modeCompare classic, async, defer, and module scripts by fetching, timing, and ordering.
- 03Diagnose loading surprisesRecognize preload discovery, document.write hazards, and the difference between modeled and browser timing.
HTML arrives before JavaScript runs
HTML parsing turns incoming markup into document objects as bytes arrive. A script tag can run before the whole page exists.
Script loading is discovery, fetching, scheduling, execution, and then continued parsing.
A tag is a scheduling instruction. Its position, src, async, defer, and type="module" change its timeline.
Separate parser position, download readiness, and execution permission. Those are different facts, even when a waterfall makes them look close together.
This continues from What happens when you navigate.
The HTML tokenizer and tree builder
The tokenizer reads characters and emits tokens such as a start tag, text, and an end tag. The tree builder consumes those tokens and places nodes into the document tree.
This happens while HTML arrives. The browser does not wait for the final byte before it can create the first paragraph. That is why a later script can see an earlier element.
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 one small piece of markup. Line 2 turns it into tokens in source order. Line 3 gives those tokens to the tree builder. The first log prints start-tag,text,start-tag,text,end-tag,end-tag.
The second log prints "Hi ",b. Those are the paragraph's direct children: text first, then the bold element. The word there belongs inside that bold element, so it is not a direct paragraph child.
Replay a small tokenizer and tree-builder teaching model. It records real calls from this lesson, not browser-engine debugging.
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 recover from imperfect HTML. This small model treats a second <p> as the start of a new paragraph, even when the first paragraph did not include its closing tag.
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 deliberately omits the first closing paragraph tag. Line 2 finds the two recovered paragraphs. The logs print 2 and then "Tea","Cake". This is a teaching model, not browser source code: real tree building has many insertion modes and recovery rules.
Parser-blocking scripts
An inline classic script runs at its parser position. It can query nodes the tree builder has already made, but it cannot query nodes that remain later in the source.
<p>Hello</p><script>console.log(document.querySelectorAll("p").length)</script><p>World</p>The first paragraph is parsed before the script. The script calls querySelectorAll while only that first paragraph exists, so it prints 1. Parsing resumes, then the browser creates the second paragraph.
An ordinary external classic script has the same position rule plus a network wait. The parser pauses while the file fetches, then pauses while that file executes. A stylesheet can delay execution too when the script might read computed styles.
Read a recipe aloud. When you reach “go and buy milk”, everything stops until the milk arrives. A second friend can skim ahead and start buying other ingredients early.
With async, the milk is handed over the moment it arrives. With defer, it is used only after the whole recipe is read, in written order.
- In real life: You reach go and buy milk
- In JavaScript: Parser reaches a classic script
- In real life: Reading stops until milk arrives
- In JavaScript: Parsing waits for fetch and execution
- In real life: A friend skims ahead for ingredients
- In JavaScript: Preload scanner discovers later URLs
- In real life: Milk used now or after reading
- In JavaScript: async or defer scheduling
Where the analogy stops: A browser coordinates many requests; the recipe does not model every network detail.
The preload scanner
A blocked parser would otherwise hide later URLs. The preload scanner skims raw markup ahead and can start useful fetches before the main parser reaches those tags.
It is not a second tree builder and it never executes page JavaScript. It can discover a normal stylesheet, image, or script URL that is plainly written in the HTML response.
<script src="/checkout.js" defer></script>The src value is visible in the response, so the browser can begin fetching /checkout.js early. The defer attribute still decides that execution waits until parsing is complete.
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 contains a visible src, so the first log prints found early. Line 2 only describes creating a script later; it has no URL in initial markup. The second log prints created later. The preload scanner cannot discover code before that code creates its URL.
async, defer, and module scripts
async fetches beside parsing and runs when ready. It fits independent work, but two async files do not promise document order. A later tag can run first when its download finishes first.
defer fetches beside parsing and runs after parsing. Deferred classic scripts keep source order, so they fit ordered startup files such as a cart file followed by a checkout file.
A module script fetches its module graph beside parsing. Without async, it has deferred-like timing; with async, it runs when its graph is ready. Imports make the graph matter, not only the file named in the HTML tag.
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 through 6 make four tag strings: ordinary classic, async classic, deferred classic, and module. Line 8 prints 4. Line 9 states the key contrast: async runs when ready, while defer waits for parsing.
| Mode | Parsing and execution | Order driver |
|---|---|---|
| Classic external | Stops parser while fetching and executing | Document position |
| async classic | Fetches beside parsing; runs when ready | Download completion |
| defer classic | Fetches beside parsing; runs after parsing | Document order |
| Module without async | Fetches module graph beside parsing; runs after parsing | Document order |
| Module with async | Fetches module graph beside parsing; runs when ready | Readiness, not document order |
Script execution order
Execution order is not one rule. It combines parser position, which downloads have become ready, and whether a script is allowed to run before parsing ends. Keeping those facts separate prevents most loading surprises.
Use one controlled model before trusting a live network trace. This model makes script kind and download completion explicit. It is a scheduling model, not a simulation of a browser engine.
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 through 6 list scripts in document order. Line 3 is inline and runs in place. Line 4 is async and becomes ready at 2. Lines 5 and 6 wait for parsing even though the module reports readiness at 1.
Line 8 prints inline settings -> checkout -> app -> catalog. Inline runs in place, async runs by readiness, and defer or non-async module entries wait for parsing. The latter two stay in their written order in this model.
Replay a deterministic teaching model of script scheduling. Browser network timing is not deterministic, so the model makes its input explicit.
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(" -> "));inlinedownloadDoneAt: 0
asyncdownloadDoneAt: 2
deferdownloadDoneAt: 5
moduledownloadDoneAt: 1
The model now runs: inline settings -> checkout -> app -> catalog. The async entry moves with readiness; deferred and module entries still wait for parsing.
Change one download time, then reset it. Only the ready async position can change; the after-parsing group still appears after parsing. A real network can vary, which is exactly why app dependencies should not rely on async order.
document.write belongs to the parser era
document.write() writes markup into the active parser stream. During an inline parser script, it can add markup at that exact point, before the parser reads the following HTML.
That behavior explains old pages that load advertisements or small snippets this way. It also makes the API fragile: the result depends on whether the browser is still parsing the original document.
document.write("<p>Added while parsing</p>");
console.log("write was part of the current document stream");Line 1 writes a paragraph into the current stream. Line 2 logs the narrow context that makes the write useful. In an inline parser script, the paragraph becomes part of the document at that position.
After load, calling it can open and replace the document instead. New code should make an element with createElement, set its content deliberately, and insert it with append. Those DOM operations do not depend on parser state.
Fix a slow page's script tags
Use a blocking classic script only when exact parser position is truly required. Most startup code should use modules or ordered deferred scripts, leaving the parser free to build the page while files download.
Imagine a shop page where checkout depends on cart setup. These two ordinary classic tags can stop parsing twice, and their source order forces each fetch into the parser's critical path.
<script src="/cart.js"></script>
<script src="/checkout.js"></script>Line 1 blocks parsing for cart.js. After it finishes, line 2 blocks parsing for checkout.js. The order is safe, but the parser is idle during each fetch.
<script src="/cart.js" defer></script>
<script src="/checkout.js" defer></script>
<script src="/analytics.js" async></script>Lines 1 and 2 now fetch beside parsing and execute after parsing in order: cart before checkout. Line 3 is independent analytics, so async allows it to execute when ready without becoming an app dependency.
This does not make a slow server fast. It removes unnecessary parser waits and makes dependency rules clear. Measure the page after changing tags: a tag choice is a scheduling decision, not a magic speed switch.
- An inline classic script
- A classic external script without attributes
- An external classic script with
async - A module script with
async - An external classic script with
defer - A module script without
async
Place each card by its timing rule, then read the explanation.
Common loading misconceptions
Async changes scheduling, not network speed. A faster download can make an async script execute earlier, but the attribute does not shrink the file or improve the server response.
The preload scanner discovers resources, not JavaScript behavior. Deferred classics retain document order; async files do not. Non-async module scripts wait for parsing and for the module graph they need.
- “Async is always faster.” It only changes when execution may happen.
- “Defer is async with a new name.” Defer waits for parsing and preserves order.
- “A preload scanner runs code.” It only finds resources that HTML already reveals.
- “document.write updates the DOM.” It writes the parser stream and depends on timing.
| Term | What it means | Not this |
|---|---|---|
async | Runs when its script is ready, possibly before parsing ends. | A way to preserve script order. |
defer | Runs after parsing and keeps document order for deferred classics. | A way to run immediately after download. |
| Module | Acts like deferred loading unless it has async. | A guarantee that defer changes its timing. |
| Preload scanner | Speculatively discovers fetchable resources in markup. | A second DOM tree builder or JavaScript executor. |
Practice exercises
A script has reached a point where one paragraph already exists and a later paragraph does not. Read the starter code as that moment in parsing. Type the output, without adding a sentence or punctuation.
const before = ["Hello"];
console.log(before.length);console.log(before.length); // 1The earlier parser work made one value available, so it prints 1.
A tiny tokenizer reads <p>Hi</p> from left to right. Give the first two token kinds, separated by a comma. Focus on what the characters mean before any tree exists.
tokenizeTinyHtml("<p>Hi</p>").slice(0, 2);The first two token kinds are start-tag, text.
Two independent scripts use async. The later one finishes downloading first in this controlled model. Predict the comma-separated output and notice why written order is not a dependency contract.
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 becomes ready at 1, before the first script at 3.
The module finishes fetching before the deferred classic file. Both still wait until parsing finishes. Type the resulting comma-separated order, using the order of the tags rather than 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 entries run after parsing, in the order their tags were written.
Your page sends a page-view event, but neither cart nor checkout reads anything from that analytics file. Choose one loading mode for its tag. The goal is to avoid creating an order dependency where none exists.
<script src="/analytics.js" async></script>Use async for independent analytics that does not provide values to startup code.
A real shop page loads cart setup first, then checkout startup reads the cart API. Fix those tags without bringing back parser blocking. Name a suitable mode, then compare your answer with the practical example.
<script src="/cart.js" defer></script>
<script src="/checkout.js" defer></script>Use defer, or non-async modules, when checkout needs cart setup and parsing should continue.
Check your understanding
Separate parser position, readiness, and document order before choosing an answer. The questions use the same small models as the article, so every output has one explicit reason.
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
- Tokens become a document tree while HTML arrives.
- Classic scripts can stop parsing at their source position.
- The preload scanner can discover visible URLs early, but cannot run code.
- Async runs when ready; defer and non-async modules wait for parsing.
- Use a clear dependency rule before changing script tags.
- Avoid document.write in new work because parser state makes it fragile.
Remember the one-liner.
A script tag is a scheduling instruction as well as code.
Coming next: The rendering pipeline.