Scripts: async, defer
On most sites, the scripts weigh more than the markup. A JavaScript bundle downloads more bytes than the HTML around it, and once downloaded it takes real CPU time to parse and run. So the question of when a script loads and when it runs matters for how fast a page feels.
Here’s the default behavior you’re fighting against. As the browser reads through your HTML, it builds the DOM tag by tag. The moment it hits a <script>...</script> tag, it stops. It has to run that script before it can look at the next line of HTML. An external script <script src="..."></script> is worse: the browser pauses, fetches the file over the network, executes it, and only then goes back to parsing the rest of the page.
That default causes two headaches:
- A script can’t touch DOM elements that appear below it in the HTML, because those elements don’t exist yet. Try to attach a click handler to a button further down the page and you’ll get
null. - A heavy script near the top of the document blocks the whole page. Nothing renders until it downloads and runs:
<p>...content before script...</p>
<script src="https://example.com/scripts/heavy.js"></script>
<!-- This isn't visible until the script loads -->
<p>...content after script...</p>
The reader stares at a blank space where the second paragraph should be, waiting on a file that has nothing to do with that paragraph.
You can watch headache #1 happen for real. Both scripts below run during parsing, exactly where they sit in the HTML. The first one runs before the button has been parsed, so it finds nothing; the second runs after and finds it. Move the button above the first script (in the HTML tab) to see both reports flip to “found it”.
The old workaround: move it to the bottom
One classic fix is to push the script to the very end of <body>. By the time the browser reaches it, every element above already exists, so the script can see them, and none of the visible content is held hostage:
<body>
...all content is above the script...
<script src="https://example.com/scripts/heavy.js"></script>
</body>
This works, but it’s not great. The browser only discovers the script after it has parsed the entire HTML document, so it can’t even start the download until the end. On a long page or a slow connection, that’s a measurable delay before the script begins fetching.
Two <script> attributes fix this properly: defer and async. Both let the download happen in the background without freezing the parser. Where they differ is when the script runs and what order multiple scripts run in.
defer
defer tells the browser: don’t wait for this script. Keep parsing HTML and building the DOM. Download the script in the background, and once the DOM is fully built, run it.
<p>...content before script...</p>
<script defer src="https://example.com/scripts/heavy.js"></script>
<!-- visible immediately -->
<p>...content after script...</p>
Two guarantees come with defer:
- A deferred script never blocks rendering. The rest of the page shows up right away.
- A deferred script always runs after the DOM is ready — after the document is parsed, but before the
DOMContentLoadedevent fires.
That second guarantee is worth seeing directly. The inline handler below waits for DOMContentLoaded, and the deferred script holds that event back until it has run:
<p>...content before scripts...</p>
<script>
document.addEventListener('DOMContentLoaded', () => alert("DOM ready after defer!"));
</script>
<script defer src="https://example.com/scripts/heavy.js"></script>
<p>...content after scripts...</p>
Play it through:
- The page content appears at once —
deferdidn’t block anything. DOMContentLoadeddoesn’t fire until the deferred script has downloaded and executed. The event patiently waits for it.
Deferred scripts keep their document order
This is the property that makes defer safe for dependencies. Say you have two deferred scripts, heavy.js first and tiny.js second:
<script defer src="https://example.com/scripts/heavy.js"></script>
<script defer src="https://example.com/scripts/tiny.js"></script>
The browser scans ahead and downloads both in parallel, so tiny.js — being smaller — probably finishes downloading first. But defer doesn’t only mean “don’t block.” It also promises that scripts run in the order they appear in the document. So tiny.js sits and waits until heavy.js has run, then runs itself.
That ordering guarantee matters whenever one file needs another. Load a library first, then the script that calls into it, and defer keeps them in the right sequence.
async
async is a cousin of defer: it also downloads in the background and never blocks rendering. The difference is what happens around the script.
An async script is treated as fully independent — it doesn’t coordinate with anything else on the page:
- The browser doesn’t block on it, same as
defer. - It doesn’t wait for other scripts, and other scripts don’t wait for it.
- It doesn’t coordinate with
DOMContentLoadedin either direction:DOMContentLoadedcan fire before an async script, if the script is still downloading when the page finishes parsing.- Or it can fire after an async script, if the script was small or already cached and ran quickly.
The short version: an async script loads in the background and runs the instant it’s ready, whenever that happens to be. Nothing waits for it, and it waits for nothing.
Here’s the same two-script setup as before, but with async instead of defer:
<p>...content before scripts...</p>
<script>
document.addEventListener('DOMContentLoaded', () => alert("DOM ready!"));
</script>
<script async src="https://example.com/scripts/heavy.js"></script>
<script async src="https://example.com/scripts/tiny.js"></script>
<p>...content after scripts...</p>
What you get:
- The content shows immediately —
asyncdoesn’t block it. DOMContentLoadedmight land before or after either script. No promises.tiny.jsis written second but usually finishes downloading first, so it usually runs first. Ifheavy.jshappened to be cached, it could win the race and run first instead. Async runs in load-first order — whoever’s ready runs, and document order is ignored.
That independence is exactly what you want for third-party scripts that stand on their own — analytics counters, ads, chat widgets. They don’t need your code, and your code shouldn’t stall waiting for them:
<!-- Analytics is usually added like this -->
<script async src="https://example.com/analytics.js"></script>
Dynamic scripts
There’s a third way to add a script: build the element in JavaScript and append it to the document yourself.
let script = document.createElement('script');
script.src = "/scripts/heavy.js";
document.body.append(script); // (*)
The download kicks off the moment the element is inserted into the document, at line (*).
The demo below builds a <script> element in JavaScript and appends it each time you click. Here the script carries its own inline code instead of a src, so it runs the instant it joins the document — you can see each injected script report the moment it executed.
Dynamically created scripts behave like async by default. Concretely:
- They wait for nothing, and nothing waits for them.
- Whichever one loads first runs first — load-first order.
You can opt out of that. Set script.async = false before appending, and the browser switches those scripts to document order, the same ordering defer gives you. The wrapper below does exactly that, so heavy.js always runs before tiny.js because it’s appended first:
function loadScript(src) {
let script = document.createElement('script');
script.src = src;
script.async = false;
document.body.append(script);
}
// heavy.js runs first because of async=false
loadScript("/scripts/heavy.js");
loadScript("/scripts/tiny.js");
Drop the script.async = false line and you’re back to the default: whichever file arrives first runs first, most likely tiny.js. The async = false trick is how you load a library and its dependent script dynamically while keeping them in the right order.
async vs defer at a glance
Both keep the download off the critical path, so the reader sees and can start reading the page while scripts load in the background. What separates them is execution order and their relationship to DOMContentLoaded.
| Order | DOMContentLoaded |
|
|---|---|---|
async |
Load-first. Document order is ignored — whichever downloads first runs first. | No relationship. An async script may run before the document finishes parsing (if it’s small or cached) or after. |
defer |
Document order. Scripts run in the order they appear, waiting if needed. | Runs after the document is parsed, right before DOMContentLoaded fires. |
Choosing between them is usually straightforward:
- Reach for
deferwhen a script needs the full DOM, or when its order relative to other scripts matters (library before its plugin, for example). - Reach for
asyncwhen a script is self-contained and its timing doesn’t affect anything else — counters, ads, and similar.