Script loading strategies
See when classic, async, defer, module, inline and injected scripts run, what each one holds back, and how to write the tags so the page shows up fast.
- 01Predict when each script runsClassic, async, defer, module, inline and added scripts, and what DOMContentLoaded and the load event wait for.
- 02Choose where a script goesIn the head or at the end of the body, written in the HTML or added by another script, and what each costs the first paint.
- 03Keep inline scripts lightSee what an inline script's work costs the first paint, and move heavy work after it.
Meera adds a chat bubble
Meera wants two new things on the tea list at Leaf and Kettle. The first is a chat bubble, so customers can ask about a tea. The second is an analytics tag: a small script that counts visits, so she knows how many people come.
Each service sends a few lines to paste into the page. Both say the same thing: paste this in the head. So Ravi pastes them next to his own app. Here is the top of the tea list now, shortened:
<head> <script>window.SHOP = { analytics: "LK-2041", chat: "leaf-and-kettle" };</script> <script src="app.js"></script> <script src="analytics.js"></script> <script src="chat-loader.js"></script></head>- Line 2 is an inline script. Its code sits inside the tag, so there is no file to fetch. It sets a few settings that the other scripts read. We'll call it the config.
- Line 3 is app.js, Ravi's app. It holds React and the tea list, and draws the teas under the shop's name. Packed with Brotli, as the server sends it, it's 112.3 KB. That's about as much as the tea list's first load in lesson 22.6.
- Line 4 is analytics.js, the analytics tag.
- Line 5 is chat-loader.js. It doesn't hold the chat itself. It adds a second script, chat-widget.js, which does.
Lines 3 to 5 are classic scripts. A classic script is a plain script tag with a src, and no async, defer or type="module". The course wrote analytics.js and both chat files itself, so every run loads exactly the same code. They're small: the chat widget sends 16.9 KB.
Then Ravi reads three blog posts. One says: always use async. One says: always use defer. An older one says: put your scripts at the end of the body. Which one is right? Does it even matter, when the files are the same?
The course loaded the tea list six ways on Asha's phone. "Asha's phone" is the stand-in from lesson 22.1: Chrome 157 on a phone's screen, DevTools' Slow 4G network, and a CPU 4 times slower. The four scripts never changed. Only their tags did. Here is app.js's tag on each page. The other two files were written the same way:
<script src="app.js"></script> <!-- 1. in the head -->
<script src="app.js"></script> <!-- 2. just before </body> -->
<script src="app.js" async></script> <!-- 3. in the head -->
<script src="app.js" defer></script> <!-- 4. in the head -->
<script src="app.js" type="module"></script> <!-- 5. in the head -->
<script src="loader.js" async></script> <!-- 6. loader.js adds app.js -->- Lines 1 to 5 load the same file five ways.
- Line 1 sits in the head. Line 2 sits at the very end of the body, just before
</body>. - Lines 3 to 5 each add one word.
asyncmeans: run the file as soon as it arrives.defermeans: run it once the whole HTML has been read.type="module"makes it a module, which also waits for the HTML. - Line 6 is different. The HTML names only loader.js. When loader.js runs, it adds the other three tags itself.
Here is what Asha saw on each page, at three moments:
At one second, five pages showed the shop's name. The first page was still white. At 2.1 seconds, four pages showed the teas, and two still didn't. At 2.5 seconds, the page with loader.js was still waiting.
Here are the numbers behind the picture. Read each row across: how the tags were written, then three moments.
- First paint is the first moment anything appeared on the screen.
- Teas on screen is when Chrome painted the tea list. It's the page's largest contentful paint.
- Could answer a tap is as in lesson 22.1. It's the later of two moments: the teas appear, and the last long task ends. A long task keeps the phone busy for more than 50 ms.
| How the tags were written | First paint | Teas on screen | Could answer a tap |
|---|---|---|---|
| Classic, in the head | 1,974 ms | 2,044 ms | 2,751 ms |
| Classic, at the end of the body | 632 ms | 2,062 ms | 2,782 ms |
| async, in the head | 653 ms | 2,228 ms | 2,228 ms |
| defer, in the head | 647 ms | 2,004 ms | 2,764 ms |
| type="module", in the head | 631 ms | 2,000 ms | 2,761 ms |
| Added by loader.js | 644 ms | 2,809 ms | 2,809 ms |
Same files, same phone, same network. Yet the first paint came anywhere from 631 ms to 1,974 ms. The teas came anywhere from 2,000 ms to 2,809 ms. Why it matters: the way you write a script tag can cost a visitor more than a second, with no change to the code inside.
You order the week's shopping online in the morning. The van brings it while you get on with your day. Ordering early costs you nothing. What decides when dinner is ready is when you cook. And if a note says "stop everything and cook this the moment it comes", your whole day waits for the van.
- In real life: Placing the order
- In web performance: Chrome asking for a script's file
- In real life: The van on its way
- In web performance: The file downloading
- In real life: Cooking
- In web performance: Running the script
- In real life: "Stop everything and cook this the moment it comes"
- In web performance: A classic script: the browser stops for it
- In real life: Dinner on the table
- In web performance: The teas on Asha's screen
A script tag decides two things: when the browser asks for the file, and when it runs it. Asking early is cheap. Running is what makes the page wait. The six ways put that wait in different places: before the first paint, before the teas, or after them.
Running happens on the main thread. That's the one line of work where the browser runs scripts and draws the page. Only one thing happens on it at a time.
Lesson 5.3 met async, defer and modules, with one slow script. The JavaScript lesson From HTML to running script explains the rules behind them. This lesson puts all of them on one real page, and measures what each one costs. It follows Ravi's questions:
- In what order did the scripts run? Execution order for each kind.
- Is the end of the body as good as
defer? The head or the end of the body. - Why does chat-widget.js always come late? Scripts added by other scripts.
- What does an inline script cost? Inline scripts.
First, see the six pages fill in. Move the slider, and watch each page change:
- Classic in the head
blank - End of the body
header - async
header - defer
header - module
header - Injected
header
Move the slider. Each frame is the last one Chrome had drawn by then, on the load in the middle of three. Only the script tags differ between the six pages.
When each kind of script runs
Start with the order. On every page, the same five pieces of code ran: the config, app.js, analytics.js, chat-loader.js and chat-widget.js. But they didn't run in the same order. Here are two of the pages:
// The order the scripts ran in, on two of the six pages.const asyncPage = ["config", "analytics.js", "chat-loader.js", "app.js", "chat-widget.js"];const deferPage = ["config", "app.js", "analytics.js", "chat-loader.js", "chat-widget.js"]; console.log(`async: ${asyncPage.join(", ")}`);console.log(`defer: ${deferPage.join(", ")}`);- Line 2 lists the async page's scripts, in the order they ran. Line 3 does the same for the defer page.
- Line 5 prints
async: config, analytics.js, chat-loader.js, app.js, chat-widget.js. - Line 6 prints
defer: config, app.js, analytics.js, chat-loader.js, chat-widget.js.
Both pages ran the config first. It's inline, so it ran the moment the parser reached it. The parser is the part of the browser that reads the HTML from top to bottom. After the config, the two orders split.
You order three dishes. You can tell the waiter: "Bring each dish as soon as it's cooked." Then the quick salad comes first, whatever you ordered first. Or you can say: "Bring them in the order I ordered, once we've all sat down." Then the salad waits for the slow soup. When the last dish is on the table, the waiter says: "That's everything."
- In real life: "Bring each dish as soon as it's cooked"
- In web performance:
async: run as soon as the file arrives - In real life: The quick salad coming first
- In web performance: analytics.js, a tiny file, running before app.js
- In real life: "Bring them in the order I ordered, once we've all sat down"
- In web performance:
defer: run after the HTML is read, in the order of the tags - In real life: The waiter saying "That's everything"
- In web performance: DOMContentLoaded: the HTML is read, and the deferred scripts have run
async: in the order the files arrive
// The async page: when each file had arrived, and when it ran (ms).const files = [ ["analytics.js", 1187, 1188], ["chat-loader.js", 1194, 1195], ["app.js", 1905, 1909],];for (const [file, arrived, ran] of files) console.log(`${file}: arrived at ${arrived}, ran at ${ran}`);- Lines 2 to 6 list three files: when each had arrived, and when it ran, in ms.
- Line 7 prints one line for each:
analytics.js: arrived at 1187, ran at 1188,chat-loader.js: arrived at 1194, ran at 1195andapp.js: arrived at 1905, ran at 1909.
Each file ran within a few ms of arriving. analytics.js is only 161 bytes with Brotli, so it came first, and ran first. app.js is 112.3 KB, so it came last. The order of the tags didn't matter at all. Why it matters: never give async to a script that needs another script to run first. On another visit, the files could arrive in another order.
defer: in the order of the tags
// The defer page: when each file had arrived, and when it ran (ms).const files = [ ["app.js", 1833, 1838], ["analytics.js", 1192, 1937], ["chat-loader.js", 1203, 1939],];for (const [file, arrived, ran] of files) console.log(`${file}: waited ${ran - arrived} ms to run`);- Lines 2 to 6 list the same three files on the defer page.
- Line 7 prints how long each one waited between arriving and running.
- It prints
app.js: waited 5 ms to run,analytics.js: waited 745 ms to runandchat-loader.js: waited 736 ms to run.
The two small files arrived at about 1.2 seconds. Then they waited, because app.js comes first in the HTML, and it hadn't arrived yet. Deferred scripts run after the whole HTML has been read, in the order of their tags. So the defer page ran its scripts in the order Ravi wrote them. Why it matters: defer is the choice when one script needs another, like a cart script that uses app.js.
The module page ran in exactly the same order as the defer page. A module script is deferred unless it has async. The next lesson looks at what else modules change.
DOMContentLoaded and the load event
DOMContentLoaded is the event the browser fires once it has read all the HTML. Lesson 5.3 showed that it waits for deferred scripts. Here is what that meant for the tea list:
// When DOMContentLoaded and the load event fired (ms).const asyncPage = { domContentLoaded: 616, load: 2225 };const deferPage = { domContentLoaded: 1940, load: 2768 }; console.log(`async: DOMContentLoaded at ${asyncPage.domContentLoaded}, load at ${asyncPage.load}`);console.log(`defer: DOMContentLoaded at ${deferPage.domContentLoaded}, load at ${deferPage.load}`);- Line 2 holds the async page's two times. Line 3 holds the defer page's.
- Lines 5 and 6 print
async: DOMContentLoaded at 616, load at 2225anddefer: DOMContentLoaded at 1940, load at 2768.
On the async page, DOMContentLoaded fired at 616 ms, right after the HTML was read. It doesn't wait for async scripts. On the defer page, it waited 1.3 seconds more, for app.js and the two small files.
The load event is different. It fires when the page and its files have finished loading. On both pages, it came after chat-widget.js had run. The HTML standard says why. Before it fires load, the browser waits for every async script, and every script added by another script, that is still on its way.
Why it matters: code that waits for DOMContentLoaded starts at very different moments. It depends on how the page's other scripts are written.
A script that may run early or late
Here's a puzzle. On the first page, app.js ran in the head, before the body existed. Its <div id="root"> wasn't there yet. So how did it draw the teas? Its last three lines start the app:
const start = () => createRoot(document.getElementById("root")).render(<App />);
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
else start();- Line 1 makes a function,
start, that draws the tea list into#root. - Line 2 checks
document.readyState. The value"loading"means the HTML is still being read. Then the app waits for DOMContentLoaded. - Line 3 starts at once, because the HTML has already been read.
MDN's page on DOMContentLoaded gives this pattern. It's for scripts that can't know how they'll be loaded. The chat widget does the same. Why it matters: a script that might run before the HTML is read must wait for the elements it needs.
Now look at every page, script by script. Pick a page:
- The config (inline)ran at 617 ms
- app.jsasked for at 609 ms (Low), arrived 1,833 ms, ran at 1,838 ms
- analytics.jsasked for at 610 ms (Low), arrived 1,192 ms, ran at 1,937 ms
- chat-loader.jsasked for at 610 ms (Low), arrived 1,203 ms, ran at 1,939 ms
- chat-widget.jsasked for at 1,939 ms (Low), arrived 2,619 ms, ran at 2,620 ms
- First paint647 ms
- Teas on screen2,004 ms
- DOMContentLoaded1,940 ms
- The load event2,768 ms
- Could answer a tap2,764 ms
Pick a page. The rows list its scripts in the order they ran: when Chrome asked for each file, the priority it started at, when the file arrived, and when it ran. Then the page's moments.
In the head, or at the end of the body
Now the old advice: put scripts at the end of the body. Was it right? Compare the first two pages. Both use classic scripts. Only the place of the tags differs:
// app.js as a classic script: in the head, and at the end of the body (ms).const head = { asked: 605, firstPaint: 1974, teas: 2044 };const body = { asked: 598, firstPaint: 632, teas: 2062 }; console.log(`Chrome asked for app.js at ${head.asked} and ${body.asked}`);console.log(`First paint at ${head.firstPaint} and ${body.firstPaint}`);console.log(`Teas on screen at ${head.teas} and ${body.teas}`);- Line 2 holds three times from the page with the tags in the head. Line 3 holds the same three from the end of the body.
- Lines 5 to 7 print
Chrome asked for app.js at 605 and 598,First paint at 1974 and 632andTeas on screen at 2044 and 2062.
Chrome asked for app.js at almost the same moment on both pages. That's the preload scanner from lesson 5.2: a second reader that skims ahead and starts fetching every file it finds. The teas also came at almost the same time, about 2 seconds. The big difference was the first paint: 1,342 ms.
A post office clerk serves a queue, one customer at a time. One customer needs the manager's signature, and the manager is out. The clerk waits with them. If that customer is first in the queue, nobody else is served until the manager comes back. If they're last, everyone else has already gone home.
- In real life: The clerk serving the queue, one by one
- In web performance: The parser reading the HTML, in order
- In real life: The customer who needs a signature
- In web performance: A classic script, waiting for its file
- In real life: First in the queue
- In web performance: In the head
- In real life: Last in the queue
- In web performance: At the end of the body
- In real life: The customers already served
- In web performance: The header, already painted
In the head, the parser stopped before it reached the body. There was nothing to paint yet. And the HTML standard counts a classic script in the head as render-blocking: the browser won't paint until it has run. At the end of the body, the parser had already built the header. Chrome painted it while the parser waited.
So the end of the body helps the first paint. Does it beat defer? On this page, no. The two were almost the same: first paint at 632 and 647 ms, teas at 2,062 and 2,004 ms. And two things keep defer ahead on real pages:
- A long page arrives in pieces. The preload scanner can only read the pieces that have arrived. A tag at the very end is found with the last piece. A tag in the head is found with the first. The tea list's HTML is small, so it arrived in one go.
- A classic script at the end of the body still stops the parser. DOMContentLoaded waited until 1,972 ms, about as long as with
defer.
The priority Chrome gave each one
When Chrome asks for a file, it gives the request a priority: how urgent it is, next to the page's other requests. Lesson 5.4 showed how it picks one. Here is where app.js started on five pages:
// The priority Chrome gave app.js when it asked for it, as DevTools names it.const priority = { "classic, head": "High", "classic, body": "High", async: "Low", defer: "Low", "added by loader.js": "Low",};for (const [page, name] of Object.entries(priority)) console.log(`${page}: ${name}`);- Lines 2 to 8 hold the priority Chrome gave app.js on five pages, in DevTools' names.
- Line 9 prints one line for each, from
classic, head: Hightoadded by loader.js: Low.
Classic scripts started High, because the parser waits for them. async, defer and added scripts started Low. Chromium's own source says so, in a comment: "Async/Defer: Low Priority". The same file starts a classic script at Medium if the preload scanner finds it after the page's first picture. The tea list has no picture above its scripts. Module scripts aren't classic, so app.js started High on the module page.
On this page, priority changed little. app.js started High on the classic and module pages, and Low on the defer page. Yet it arrived between 1,818 and 1,833 ms on all four. On a page with many files fighting for the network, it matters more.
Why it matters: put scripts in the head, with defer. The browser finds them in the first piece of HTML, and the first paint doesn't wait for them.
Scripts added by other scripts
One file was late on every page: chat-widget.js. Here is chat-loader.js, the snippet the chat service gave Meera:
// chat-loader.js: adds the chat widget to the page.const script = document.createElement("script");script.src = "chat-widget.js";script.dataset.team = window.SHOP.chat;document.head.append(script);- Line 2 makes a new script element. Line 3 names the file it should load.
- Line 4 passes on the shop's chat name, from the config.
- Line 5 adds the element to the head. Only now does Chrome ask for the file.
A script that another script adds to the page is called an injected script. The preload scanner can't see it. It reads the HTML, and the HTML never names chat-widget.js. Chrome only learns about the file when line 5 runs:
// When chat-loader.js ran, and when Chrome asked for chat-widget.js (ms).const pages = [ ["classic, head", 1943, 1943], ["async", 1195, 1196], ["defer", 1939, 1939],];for (const [page, ran, asked] of pages) console.log(`${page}: loader ran at ${ran}, widget asked for at ${asked}`);- Lines 2 to 6 hold two times for three pages: when chat-loader.js ran, and when Chrome asked for chat-widget.js.
- Line 7 prints one line for each page. The first is
classic, head: loader ran at 1943, widget asked for at 1943. - Then
async: loader ran at 1195, widget asked for at 1196anddefer: loader ran at 1939, widget asked for at 1939.
On every page, the request left the moment the loader ran, never sooner. On the defer page, chat-loader.js had arrived at 1,203 ms. But it waited in line behind app.js. So the widget was asked for 736 ms after its loader had arrived.
A friend sends you a voucher. To get the gift, you post the voucher back to the shop, and the shop sends the gift. Nobody can send the gift sooner, because nobody knows you want it until the voucher comes back. That's two trips by post, where one would do.
- In real life: The voucher
- In web performance: chat-loader.js
- In real life: Posting the voucher back
- In web performance: chat-loader.js running, and adding the tag
- In real life: The gift
- In web performance: chat-widget.js
- In real life: Nobody knows you want the gift yet
- In web performance: The preload scanner can't see the file
A page where every script is added
The sixth page went further. Its HTML named only loader.js:
// loader.js: adds the page's three scripts.for (const file of ["app.js", "analytics.js", "chat-loader.js"]) { const script = document.createElement("script"); script.src = file; document.head.append(script);}- Line 2 goes through the three files. Lines 3 and 4 make a script element for each one.
- Line 5 adds it to the head.
Every file now needed one more trip on the network before Chrome could even ask for it:
// app.js written in the HTML with async, or added by loader.js (ms).const written = { asked: 606, teas: 2228 };const added = { asked: 1180, teas: 2809 }; console.log(`Added: asked for ${added.asked - written.asked} ms later`);console.log(`Added: teas on screen ${added.teas - written.teas} ms later`);- Line 2 holds app.js written in the HTML with
async. Line 3 holds app.js added by loader.js. - Lines 5 and 6 print
Added: asked for 574 ms laterandAdded: teas on screen 581 ms later.
Chrome couldn't ask for app.js until loader.js had arrived and run. That cost a whole extra trip on Slow 4G. The teas appeared at 2,809 ms, the latest of all six pages.
Added scripts also behave like async ones. The HTML standard gives every script element a hidden flag called force async. It starts as true, and the parser turns it off for the tags it reads from the HTML. A script made by a script keeps it. So the three files ran in the order they arrived: analytics.js, chat-loader.js, then app.js. And Chrome gave each one Low.
web.dev's article on the preload scanner sums it up: "if a script is needed during startup, don't defeat the preload scanner by injecting it into the DOM." For the chat, lesson 19.2 had another answer: a facade, which loads the widget's code only when someone taps the bubble.
Why it matters: write the tags of the scripts the page needs in the HTML. Leave adding scripts with code for the ones that can come late.
Inline scripts
The config, on line 2 of the head, is inline. It cost nothing to fetch, because its code came inside the HTML. It took a millisecond or less to run. Inline scripts are good for small jobs like that.
But an inline script runs right where the parser meets it, and the parser waits while it runs. A week later, Meera tries an offers tool. Its instructions say: paste this inline, at the top of the head, so the offer is ready early. Ravi pastes it on the defer page. The loop stands in for the tool's real work:
<script>// The offers tool: score each of 300 teas against its past sales.let best = 0;let bestScore = -1;for (let tea = 0; tea < 300; tea++) { let score = 0; for (let sale = 0; sale < 30000; sale++) score = (score * 31 + tea + sale) % 1000003; if (score > bestScore) [best, bestScore] = [tea, score];}window.SHOP.offer = best;</script>- Lines 1 and 11 make it an inline script.
- Lines 3 and 4 keep the best tea so far, and its score.
- Lines 5 to 9 go through 300 teas. Line 7 scores each one against 30,000 made-up sales.
- Line 10 saves the winner in the config, for app.js to show.
// The defer page, without and with the offers tool's inline script (ms).const without = { firstPaint: 647, teas: 2004 };const inline = { firstPaint: 853, teas: 2013 }; console.log(`First paint: ${inline.firstPaint - without.firstPaint} ms later`);console.log(`Teas on screen: ${inline.teas - without.teas} ms later`);- Line 2 holds two times from the defer page without the offers tool. Line 3 holds the same two with it.
- Lines 5 and 6 print
First paint: 206 ms laterandTeas on screen: 9 ms later.
The offers tool kept the phone busy for 209 ms, from the moment the HTML arrived. Asha's screen stayed white the whole time. The teas hardly moved, because app.js was still downloading anyway. And Chrome had already asked for app.js, at 596 ms, before the tool ran. The preload scanner had found it.
A café opens at eight. One morning, the owner counts the till first. Customers wait outside for ten minutes, though the coffee is ready. The next day, she opens the doors at eight and counts while the first coffees brew. The counting takes just as long. But nobody waits for it.
- In real life: Counting the till
- In web performance: The offers tool's work
- In real life: Counting before the doors open
- In web performance: A plain inline script in the head
- In real life: Opening the doors
- In web performance: The first paint
- In real life: Counting while the first coffees brew
- In web performance:
type="module": after the paint, while app.js downloads
How do you move inline work later? Here are two ways to write the tag:
<script defer>/* the same offers code */</script>
<script type="module">/* the same offers code */</script>- Line 1 adds
defer. The HTML standard doesn't allowdeferon an inline script, and browsers ignore it there. MDN says "it would have no effect". The script runs during parsing, just as before. - Line 2 makes it a module. An inline module script is deferred, like one with a file. It runs after the HTML has been read.
As a module, the first paint came at 656 ms, about as early as on the page without the tool. The tool's 209 ms ran once the HTML had been read, while app.js was still on its way. The teas came at 2,013 ms. Here are the two ways side by side:
One more cost. A plain inline script that comes after a stylesheet waits for that stylesheet. MDN's page on DOMContentLoaded says scripts that aren't deferred or async "will wait for already-parsed stylesheets to load". The tea list's styles are inline, so nothing made the config wait.
Compare all four versions of the page, including the one with defer:
- The offers tool ranfrom 603 ms to 812 ms, 209 ms
- Chrome asked for app.js596 ms
- First paint853 ms
- Teas on screen2,013 ms
- DOMContentLoaded1,946 ms
- Could answer a tap2,765 ms
Pick how the offers tool's tag is written. Every page is the defer page; the first one has no offers tool at all. The rows show when the tool ran, and what Asha saw.
Why it matters: keep inline scripts small. If one must do heavy work, make it a module, or move the work into a file with defer.
A way for every script
Here is everything the pages showed, in one table. Read each row across: the tag, whether the preload scanner can find it, the priority Chrome started app.js at, when it runs, and whether DOMContentLoaded waits for it.
| The tag | Found by the preload scanner? | app.js started at | When does it run? | Does DOMContentLoaded wait? |
|---|---|---|---|---|
<script src> in the head | Yes. | High. | At once. The parser waits for the file and the run. | Yes. |
<script src> at the end of the body | Yes, once the end of the HTML has arrived. | High. | At once. The parser waits, but the page above is painted. | Yes. |
async | Yes. | Low. | As soon as its file arrives, in any order. | No. |
defer | Yes. | Low. | After the HTML is read, in the order of the tags. | Yes. |
type="module" | Yes. | High. | After the HTML is read, in the order of the tags. | Yes. |
| Added by another script | No. It isn't in the HTML. | Low. | As soon as its file arrives, like async. | No. |
Inline <script> | There's nothing to fetch. | No request. | At once, while the parser waits. | Yes. |
Inline <script type="module"> | There's nothing to fetch. | No request. | After the HTML is read, in the order of the tags. | Yes. |
web.dev gives a simple rule for scripts from other companies: always load them with async or defer, "unless the script has to run before the page can be rendered". Use defer when a script needs the page or another script. Use async for a script that stands alone. Keep a plain script only for a tiny job the first paint needs, and make it inline. And if one script must arrive sooner than the others, fetchpriority="high" works on script tags too.
Now sort these scripts. Which way should each one load?
- Ravi's app.js, which draws the teas into the page
- cart.js, which uses functions from app.js
- The chat widget's loader, which nobody needs before the teas
- An analytics tag that counts visits and needs nothing else
- An ad tag that reports when the page was seen
- Three lines that pick the dark theme before the first paint
- The settings other scripts read, like window.SHOP
Sort each script by how its tag should be written.
Common mix-ups
- "Scripts at the end of the body are fetched late." No. The preload scanner finds them as soon as that part of the HTML arrives. On the tea list, Chrome asked for app.js at 598 ms there, and at 605 ms in the head.
- "async is always faster than defer." No. On the async page, the teas came 224 ms later than on the defer page, because the chat widget ran before they were painted.
- "defer on an inline script runs it later." No. Browsers ignore
deferon a script with nosrc. Usetype="module"instead. - "DOMContentLoaded means the page's scripts have run." No. It doesn't wait for async or added scripts. On the async page, it fired at 616 ms, before any of them had run.
- "A script added with createElement runs where it was added." No. It runs as soon as its file arrives, like an async script.
- "Inline scripts are free, because there's nothing to download." No. They run on the main thread while the parser waits. The offers tool held the first paint back 206 ms.
Practice exercises
Answer each one first, then open the hints if you need them.
Both tags are in the head. The comments say when each file arrives. Which file runs first?
<script src="reviews.js" async></script> <!-- arrives at 900 ms -->
<script src="stars.js" async></script> <!-- arrives at 400 ms -->stars.js. It arrives at 400 ms, 500 ms before reviews.js, and an async script runs as soon as it arrives.
On the module page, when did Chrome ask for chat-widget.js, in ms?
1,926 ms. chat-loader.js ran at 1,926 ms, after app.js and analytics.js, in the order of the tags, and added the widget's tag at once.
Ravi wants loader.js's three scripts to run in the order of the list, like defer. What line goes where the comment is?
// loader.js: adds the page's three scripts.
for (const file of ["app.js", "analytics.js", "chat-loader.js"]) {
const script = document.createElement("script");
script.src = file;
// Your line here
document.head.append(script);
}script.async = false;The HTML standard's async setter turns force async off. Scripts added that way go on a list that runs in the order they were added, each as soon as it and those before it have arrived.
On the page with <script defer> around the offers tool, when was the first paint, in ms?
882 ms, close to the 853 ms of the plain inline script. defer did nothing: the tool still ran during parsing, before the first paint.
Open the course's copy of the defer page in Chrome, and let it load. How many script elements does its head hold now?
5. The HTML has four: the config and three deferred tags. chat-loader.js adds a fifth, chat-widget.js, at the end of the head.
Which attribute may an inline module script have, but an inline classic script may not?
async. An inline classic script can't have it, but an inline module can. With async, the module runs as soon as its imports are ready, not after the HTML is read.
Check your understanding
For each question, ask two things: when was the file asked for, and when did it run?
Question 1 of 7What does the first line of the order example print?
Read the code, then predict// The order the scripts ran in, on two of the six pages. const asyncPage = ["config", "analytics.js", "chat-loader.js", "app.js", "chat-widget.js"]; const deferPage = ["config", "app.js", "analytics.js", "chat-loader.js", "chat-widget.js"]; console.log(`async: ${asyncPage.join(", ")}`); console.log(`defer: ${deferPage.join(", ")}`);Choose an answer to see the explanation.
Question 2 of 7Why did the page with classic scripts in the head stay white for so long?
Choose an answer to see the explanation.
Question 3 of 7What does DOMContentLoaded wait for?
Choose an answer to see the explanation.
Question 4 of 7Why was chat-widget.js asked for so late on every page?
Choose an answer to see the explanation.
Question 5 of 7On the async page, why did the teas appear later than on the defer page?
Choose an answer to see the explanation.
Question 6 of 7What does defer do on an inline script, one with no src?
Choose an answer to see the explanation.
Question 7 of 7On this small page, why did the classic scripts at the end of the body do about as well as defer?
Choose an answer to see the explanation.
Key takeaways
- A script tag decides when the file is asked for and when it runs. Asking early is cheap. Running makes the page wait.
- Classic scripts run in place and stop the parser. In the head, they kept Asha's screen white until 1,974 ms.
asyncscripts run as they arrive, in any order.deferand module scripts run in the order of their tags, after the HTML is read.- DOMContentLoaded waits for defer and module scripts. The load event waits for async and added scripts.
- Added scripts are hidden from the preload scanner, start Low, and act like async ones. Each costs one more trip.
- Inline scripts cost no request, but run while the parser waits.
type="module"moves their work after the first paint.deferdoesn't.
Remember the one-liner.
Write every tag in the HTML so it's found early, and let only what the first paint needs run before it.
Sources
The facts on this page come from these standards, docs and source code:
- The script element, HTML Living Standard, WHATWG.
- The end: what happens after parsing, HTML Living Standard, WHATWG.
- Document: DOMContentLoaded event, MDN.
- Render-blocking mechanism, HTML Living Standard, WHATWG.
- resource_fetcher.cc: how Chrome picks a request's priority (157.0.8103.52), Chromium source.
- script_loader.cc: which scripts load as Low (157.0.8103.52), Chromium source.
- Don't fight the browser preload scanner, web.dev.
- <script>: The Script element, MDN.
- Efficiently load third-party JavaScript, web.dev.
- HTML attribute: fetchpriority, MDN.
Coming next: ES modules in the browser. The tea list's scripts now load in the right order. Next, what happens when the browser loads a whole tree of modules itself.