#Loading scripts: defer, async and type="module"
The browser builds a page by reading the HTML from top to bottom and turning each tag into a DOM node as it goes. When that parser meets a classic <script>, it has to stop and run the script before it parses anything else. The reason is historical but still binding: a script is allowed to change what comes next — it can call document.write() to inject more markup, or read and modify the elements parsed so far — so the parser can't safely carry on until the script has been downloaded and executed.
That costs you twice. First, the user stares at a blank or half-built page while the script travels over the network; a large script in the <head> delays everything below it. Second, the script can only see elements above it, so document.getElementById for anything further down returns null. The old workaround was to put scripts at the end of <body>. defer, async and modules fix it properly by letting the download happen in parallel with parsing and only differing in when the script runs.
A mental model: the parser is someone reading a book aloud. A plain script is a phone call they must make and finish before reading the next sentence. async is a parcel someone else fetches — the reader is interrupted the moment it arrives, whatever page they're on. defer is a stack of notes to read, in order, once the last chapter is done.
<script src="app.js"></script> <!-- blocks parsing -->
<script src="app.js" defer></script> <!-- runs after parsing, in order -->
<script src="analytics.js" async></script> <!-- runs as soon as it arrives, any order -->
<script type="module" src="main.js"></script> <!-- deferred by default + import/export -->- Plain
<script src>— the parser reaches the tag, stops, requestsapp.js, waits for the network, runs the file, and only then moves to the next tag. During that wait nothing below the tag exists in the DOM, and nothing new is shown. (Browsers have a preload scanner that peeks ahead and starts downloading later files early, but it can't build DOM, so parsing is still stuck.) defer— the download starts the moment the parser sees the tag, and parsing carries on. When the whole document has been parsed, deferred scripts run in the order they appear in the HTML — even if a later one finished downloading first — and thenDOMContentLoadedfires.deferonly works on external scripts; on an inline<script>with nosrcit is ignored.async— also downloads in parallel, but runs the moment it arrives, pausing the parser just for the execution. Twoasyncscripts run in whichever order their downloads finish, andDOMContentLoadeddoesn't wait for them, so one might run before the DOM is complete and another after.type="module"— deferred by default, and unlike classic scripts this applies to inline modules too. Before running, the browser also fetches every file in itsimportgraph. Addingasyncto a module makes it run as soon as it and all its imports are ready, ignoring order.- Only the first line makes the user wait for the network. The other three differ in when they run and whether order is kept — which is exactly what the diagram and table below compare.
async pauses parsing just to run; defer and modules wait until parsing is done.| Blocks parsing during download | Runs | Order kept | DOM ready when it runs | |
|---|---|---|---|---|
<script> | Yes | Immediately | Yes | Only elements above it |
async | No | As soon as downloaded | No | Not guaranteed |
defer | No | After parsing, before DOMContentLoaded | Yes | Yes |
type="module" | No | Like defer (add async to change) | Yes | Yes |
The second cost — a script only seeing what's above it — is the bug most people hit first:
<head>
<script>
const btn = document.getElementById("save");
btn.addEventListener("click", save); // TypeError — btn is null
</script>
</head>
<body>
<button id="save">Save</button>
</body>- The parser reaches the inline script in the
<head>and runs it immediately. At this moment the DOM holds only<html>,<head>and the script itself —<body>hasn't been read yet. getElementById("save")doesn't throw; it simply finds nothing and returnsnull. Sobtnisnull.null.addEventListener(…)throws aTypeError(Chrome words it Cannot read properties of null). The rest of the script is abandoned, and the button later appears on screen with no handler — the page looks fine but does nothing.- Fixes: move the code to an external file loaded with
defer, make ittype="module"(which defers even inline code), or wrap it in aDOMContentLoadedlistener. Addingdeferto this inline script would do nothing, becausedeferneeds asrc.
defer, async or moduleIn practice, modern build tools already do this for you — Vite's index.html, for example, loads your entry point with <script type="module">. You'll still meet async on analytics and ad snippets, and you'll still hit the null bug above in hand-written pages and in interview questions about why a script "can't find" an element.