Ch. 5 · HTML & CSS

Script async vs defer vs type=module: Loading Order Explained

When a plain script blocks parsing, why async runs in arrival order and defer in document order, and how module scripts fit, tested in Chrome.

~6 min readintermediateupdated Oct 20, 2026

The short answer: a plain <script src> stops the HTML parser until the file has downloaded and run. Both async and defer let the download happen in the background while parsing continues. The difference is when they run. An async script runs the moment it finishes downloading, which might be in the middle of parsing and in any order relative to other async scripts. A defer script waits until the whole document is parsed, then runs in the order it appears in the HTML, just before DOMContentLoaded fires. Module scripts (type="module") behave like defer unless you also mark them async.

The rule of thumb that follows: use defer (or a module) for your application code, which needs the DOM and usually depends on other scripts. Use async for independent scripts that don’t touch your code or care about order, such as analytics.

The four options compared

Plain <script src> async defer type="module"
Blocks the parser while downloading Yes No No No
When it runs Immediately, at its position As soon as it downloads After parsing finishes After parsing finishes (like defer)
Order between scripts Document order Whichever arrives first Document order Document order
Can see elements after it No Not reliably Yes Yes
Relation to DOMContentLoaded Before Before or after, no guarantee Before Before
Works on inline scripts Yes No (ignored for classic inline) No (ignored for inline) Inline modules are deferred too
Strict mode, own scope No No No Yes; top-level variables aren’t globals

Two details from that table come up in follow-ups. async and defer only mean something on scripts with a src: on a classic inline script the browser ignores them. And if both attributes appear on a classic script, async wins (old browsers that didn’t support async fell back to defer, which was the point of writing both).

What the browser actually did

To get real output rather than diagrams, I served a test page from a small Node server that delays each script response, and loaded it in Chrome 152. Every script logs its name, whether the <h1> exists yet, and document.readyState. The head looked like this:

<script async src="/a-slow.js"></script>   <!-- 300 ms -->
<script async src="/b-fast.js"></script>   <!-- 50 ms -->
<script defer src="/d-slow.js"></script>   <!-- 300 ms -->
<script defer src="/e-fast.js"></script>   <!-- 50 ms -->
<script type="module" src="/m.js"></script> <!-- 10 ms -->
<script>L('inline classic')</script>
HTML

The body started with a plain <script src="/blocking.js"> that the server delayed by 600 ms, followed by the <h1> and one more inline script. The log:

inline classic, headingFound=false @readyState=loading
b-fast.js ran, headingFound=false @readyState=loading
a-slow.js ran, headingFound=false @readyState=loading
blocking.js ran, headingFound=false @readyState=loading
inline at end of body @readyState=loading
d-slow.js ran, headingFound=true @readyState=interactive
e-fast.js ran, headingFound=true @readyState=interactive
m.js ran, headingFound=true @readyState=interactive
DOMContentLoaded @readyState=interactive
load @readyState=complete
Text

Read it line by line, because each one demonstrates a rule:

  • The plain script blocked everything below it. For 600 ms the parser sat on blocking.js and the <h1> didn’t exist. On a real site that’s 600 ms of blank or half-rendered page, which is why plain scripts in <head> hurt first paint.
  • async ran in arrival order, during parsing. b-fast.js was listed second but arrived first, so it ran first. Both async scripts executed while the parser was still stuck, and neither could find the <h1>. Code that does document.querySelector('h1').addEventListener(...) would throw a TypeError here.
  • defer kept document order. d-slow.js took six times longer than e-fast.js, yet ran first because it comes first in the HTML. Both waited until parsing finished (readyState became interactive), and both found the heading.
  • The module ran with the deferred scripts. Although m.js downloaded fastest, it waited for parsing and ran after the two defer scripts that appear before it in the document.
  • DOMContentLoaded fired after all deferred scripts. That’s specified behaviour: deferred and module scripts must finish before the event. Async scripts make no such promise.

A caveat about the first attempt: before adding the slow blocking script, the page was so small that parsing finished within a few milliseconds, and the async scripts ran after parsing, apparently “in order”. That’s the trap. On a fast local page, async can look safe; on a real page with a big document or slow network, it runs whenever it lands. Ordering bugs with async often appear only in production.

Choosing in real projects

Application bundles: defer or type="module". Your entry script needs the DOM and probably imports or depends on other code. With defer you can put the tag in <head>, so the browser discovers and starts the download early, without blocking rendering. Modern build tools such as Vite emit <script type="module" src="...">, which gets the same timing for free plus module scope.

Independent third-party tags: async. An analytics or ad script doesn’t depend on yours, and you don’t depend on it. Letting it run whenever it arrives is fine, and you don’t want it waiting behind your bundle. Even async scripts compete for bandwidth and main-thread time, though. Some sites delay such tags until after the load event or the first user interaction for that reason.

Scripts at the end of <body>. This old pattern also avoids blocking the visible content, but the browser can’t start downloading until the parser reaches the tag. defer in <head> lets the download begin as soon as the head is read, so it’s usually as good or better.

When a plain blocking script is right. Very small inline scripts that must run before first paint, like a theme bootstrap that reads a saved dark-mode preference and sets a class on <html>, should block. Deferring them causes a flash of the wrong theme.

Misconceptions interviewers listen for

“defer waits for the load event.” It doesn’t. It waits for parsing, not for images or stylesheets to finish loading. In the log, deferred scripts ran well before load.

“async is faster than defer.” Both download in parallel. async may execute earlier, but that’s only useful when the script has no dependencies. It can also execute in the middle of parsing, briefly pausing the parser while it runs.

“Dynamically inserted scripts block.” A script created with document.createElement('script') and appended behaves like async by default. Set script.async = false if you need several injected scripts to run in insertion order.

“Modules need defer.” Writing <script type="module" defer> is legal but redundant, since modules are already deferred. <script type="module" async> is the one that changes behaviour.

Forgetting nomodule. Older setups shipped a module bundle plus a legacy bundle with <script nomodule>, which only browsers without module support run. You’ll see it in older codebases even though current browsers all support modules.

Practice question: fix the race

A page has this in <head>:

<script async src="https://cdn.example.com/chart-lib.js"></script>
<script async src="/js/dashboard.js"></script>
HTML

dashboard.js calls ChartLib.render(document.getElementById('sales')). Users occasionally see ReferenceError: ChartLib is not defined and sometimes Cannot read properties of null. Explain both and fix it.

Answer

Both scripts run on arrival, so their order is a race. When dashboard.js arrives first, ChartLib doesn’t exist yet: the ReferenceError. Even when the library wins, dashboard.js may run before the parser has created #sales, so getElementById returns null and the call reading a property of it fails. Changing both to defer fixes both problems at once: they run in document order (library first) and only after parsing, so the element exists. The CDN download still happens in parallel, so you lose nothing on speed. An alternative is bundling the library into a single module entry point. Keeping async on either one would leave a race.

More in HTML & CSS

read ✓HTML & CSS · hard

HTML Image Loading and Page Priorities

When loading="lazy" helps and when it delays your LCP image, how fetchpriority and preload change the queue, and why width and height matter.

~6 min readread →
read ✓HTML & CSS · mid

HTML Responsive Images and Source Selection

How browsers pick a file from srcset using sizes and pixel density, when to use picture, and the math for a phone, a tablet and a laptop.

~6 min readread →
read ✓HTML & CSS · hard

HTML Data Tables and Header Relationships

How th, scope, caption and headers/id tell a screen reader which headers describe a cell, with a two-level pricing table worked through.

~7 min readread →
esc