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.

parse HTML
⏸ download script
⏸ run script
parse rest of HTML
The page below the tag is frozen until the red steps finish.
A plain script pauses HTML parsing while it downloads and runs.

That default causes two headaches:

  1. 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.
  2. 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”.

interactiveA script sees only what's above 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 DOMContentLoaded event fires.
parser thread
parse all HTML → DOM ready
run deferred script
DOMContentLoaded
network (background)
download heavy.js (does not block parsing)
With defer, the download overlaps HTML parsing and the script runs only after the DOM is built.

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:

  1. The page content appears at once — defer didn’t block anything.
  2. DOMContentLoaded doesn’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.

downloads finish (parallel)
tiny.js ✓ first
heavy.js ✓ later
execution (document order)
1. heavy.js
2. tiny.js (waited)
Both deferred scripts download in parallel, but execution follows document order.

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 DOMContentLoaded in either direction:
    • DOMContentLoaded can 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 — async doesn’t block it.
  • DOMContentLoaded might land before or after either script. No promises.
  • tiny.js is written second but usually finishes downloading first, so it usually runs first. If heavy.js happened 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.
downloads race in parallel
tiny.js ✓ wins the race
heavy.js ✓ arrives later
execution (load-first, no waiting)
1. tiny.js — runs on arrival
2. heavy.js — runs on arrival
Async scripts race independently; the first to download is the first to run.

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.

interactiveCreate and append a script element

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 defer when a script needs the full DOM, or when its order relative to other scripts matters (library before its plugin, for example).
  • Reach for async when a script is self-contained and its timing doesn’t affect anything else — counters, ads, and similar.