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>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=completeRead 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.jsand 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.jswas 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 doesdocument.querySelector('h1').addEventListener(...)would throw aTypeErrorhere. - defer kept document order.
d-slow.jstook six times longer thane-fast.js, yet ran first because it comes first in the HTML. Both waited until parsing finished (readyStatebecameinteractive), and both found the heading. - The module ran with the deferred scripts. Although
m.jsdownloaded fastest, it waited for parsing and ran after the twodeferscripts that appear before it in the document. DOMContentLoadedfired 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>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.