ParsingGENERALSPEC-EVOLVINGFRAMEWORK-SPECIFIC

`defer`, `async` and `type="module"`

Three genuinely different orderings: defer keeps document order and runs before DOMContentLoaded, async runs whenever it arrives with no order guarantee, and modules are deferred by default.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Which script-loading attribute should this tag have, and what ordering guarantee am I actually getting?

The user intent

Someone wants a page that works. The developer wants their scripts to run — in the right order, early enough to matter, without holding up the content.

The obvious build

Add async to everything so nothing blocks. If something breaks, switch that one to defer.

Why it breaks

async scripts execute in completion order, which is network order. Two scripts where one depends on the other work every time locally, and fail for the fraction of users whose connection delivered them the other way round.

How it breaks in a real browser
  • async scripts execute in completion order, which is network order. Two scripts where one depends on the other work every time locally, and fail for the fraction of users whose connection delivered them the other way round.
  • async still executes on the main thread, and it may execute *during* parsing — pausing tree construction at an arbitrary point determined by when its bytes finished arriving.
  • async does not wait for DOMContentLoaded and does not delay it. A script that queries the DOM may or may not find the element, depending on the same network timing.
  • defer and async are ignored on inline classic scripts. Adding defer to an inline <script> block does nothing at all, and the script runs synchronously where it sits.
  • type="module" is already deferred, so adding defer to it is redundant — but adding async to it is not, and changes the ordering guarantee completely.
  • Module scripts are always fetched in CORS mode, so a cross-origin module without the right response headers fails to load where the same file as a classic script would have worked (CORS).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Classic, no attribute. Parser-blocking. Fetch and execute happen at the tag position, in document order, with the parser suspended (Why a Script Tag Stops the Parser).
  • Classic with `defer`. Fetched in parallel with parsing, without blocking it. Execution is deferred until parsing has finished, and deferred scripts execute in document order, before DOMContentLoaded fires.
  • Classic with `async`. Fetched in parallel. Executed as soon as it is available — which may be mid-parse, briefly suspending the parser. Order is not guaranteed between async scripts, and it may run before or after DOMContentLoaded.
  • Both `async` and `defer`. async wins in any browser that supports it; defer is the fallback for engines that do not, which today means none of them.
  • `type="module"`. Deferred by default, external or inline. Non-async module scripts join the same ordered list as deferred classic scripts and execute in document order, before DOMContentLoaded.
  • `type="module" async`. Executes as soon as its dependency graph has been fetched, with no ordering guarantee. Unlike classic async, this works on inline modules too.
  • Module graph fetching. A module's imports are resolved and fetched before it evaluates, so a deferred module tag is a request for a whole subgraph, not one file (The Module Graph).
  • Module identity. A module is evaluated once per resolved URL. Importing the same specifier from ten places gives one evaluation and one shared instance (ESM vs CommonJS).
  • Dynamically inserted scripts. A <script> created with document.createElement defaults to async = true regardless of markup. Setting script.async = false before insertion restores ordered execution — the classic loader trick.

What this makes the browser do

And which of it is avoidable.

  • Parallel fetching for every non-blocking form, so network time overlaps parse time instead of following it.
  • For modules, resolving the import graph — each newly discovered specifier is another fetch, and the depth of the graph becomes round trips on a cold connection (Code Splitting).
  • Execution on the main thread in every case. None of these attributes moves work off it; they only move *when* it happens (What the Main Thread Owns).
  • Holding deferred scripts until parsing completes, then executing them back to back — which can concentrate several short tasks into one long one (Long Tasks).
  • Avoidable: the parser suspension, entirely. Not avoidable: the execution cost, which is the same wherever it lands (The Real Cost of JavaScript).

Four behaviours, stated exactly

The table is worth memorising, because every one of these rows is a guarantee you can rely on or a guarantee you do not have. The two columns that cause production bugs are "order guaranteed" and "relative to DOMContentLoaded" — everything else is performance, and those two are correctness.

Note the asymmetry in the inline column. defer and async are ignored on inline *classic* scripts, but a type="module" inline script is deferred, and async on an inline module is honoured. That is not an inconsistency for its own sake: a module has a fetchable dependency graph even when its own body is inline.

FormBlocks the parser?Fetched in parallel?When it executesOrder guaranteed?Relative to DOMContentLoadedWorks inline?
<script src>Yes — fully suspendedNo — fetch happens at the tagAt the tag positionYes — document orderBefore itn/a (inline runs at the tag)
<script defer src>NoYesAfter parsing completesYes — document order, with non-async modulesBefore it — DOMContentLoaded waitsNo — ignored on inline classic
<script async src>Only while it executesYesAs soon as it has arrived — possibly mid-parseNo — completion orderEither side — it neither waits nor delaysNo — ignored on inline classic
<script type="module">No — deferred by defaultYes, plus its whole import graphAfter parsing completesYes — document order, with deferred classicsBefore it — DOMContentLoaded waitsYes — inline modules are deferred too
<script type="module" async>Only while it executesYes, plus its import graphAs soon as the graph has arrivedNoEither sideYes — async applies to inline modules
document.createElement('script')NoYesAs soon as it has arrivedNoasync defaults to trueEither siden/a — set .async = false for order
The same four tags, annotated
1<!-- Suspends tree construction for a full round trip. Reserve for
2 code the first paint genuinely depends on, and keep it inline. -->
3<script src="/blocking.js"></script>
4
5<!-- The default you want. Parallel fetch, document order,
6 runs after parsing and before DOMContentLoaded. -->
7<script defer src="/a.js"></script>
8<script defer src="/b.js"></script>
9<!-- a.js is guaranteed to evaluate before b.js. -->
10
11<!-- No ordering relationship with anything. Only safe because
12 nothing depends on it and it depends on nothing. -->
13<script async src="/beacon.js"></script>
14
15<!-- Deferred already. `defer` here would be inert. -->
16<script type="module" src="/app.js"></script>
17
18<!-- Inline modules are deferred toounlike inline classic scripts. -->
19<script type="module">
20 import { boot } from '/boot.js'
21 boot()
22</script>
23
24<!-- Injected scripts default to async, whatever the markup would have done. -->
25<script>
26 const s = document.createElement('script')
27 s.src = '/late.js'
28 s.async = false // <- restores ordered execution against other injected scripts
29 document.head.append(s)
30</script>

The s.async = false line is the whole reason old script loaders worked. Without it, two injected scripts have no ordering relationship at all, regardless of the order you appended them.

The same page, four ways

Below is one document with two scripts of equal size, drawn under each loading model. The shape is what transfers, not the numbers: watch where parsing has a hole in it, where the two scripts execute relative to each other, and where DOMContentLoaded lands.

The async rows are drawn twice deliberately. Both orderings are legal outcomes of the same markup on the same page, decided entirely by which response finished first — and if b.js uses something a.js defines, one of those two loads is a crash.

Two scripts, four loading modelsrelative units — a schematic shape, not a measurement
BLOCKING — parse to first script
BLOCKING — fetch a.js (parser stopped)
BLOCKING — execute a.js
BLOCKING — fetch b.js (parser stopped again)
BLOCKING — execute b.js
BLOCKING — parse rest, then DOMContentLoaded
DEFER — parse whole document, uninterrupted
DEFER — fetch a.js in parallel
DEFER — fetch b.js in parallel
DEFER — execute a.js (document order)
DEFER — execute b.js
DEFER — DOMContentLoaded
ASYNC (b wins) — parse, interrupted twice
ASYNC (b wins) — fetch a.js
ASYNC (b wins) — fetch b.js
ASYNC (b wins) — execute b.js MID-PARSE
ASYNC (b wins) — execute a.js
ASYNC (a wins) — identical markup, other outcome
MODULE — parse whole document
MODULE — fetch app.js
MODULE — fetch its imports (second round trip)
MODULE — evaluate graph, document order
MODULE — DOMContentLoaded
  • BLOCKING — fetch a.js (parser stopped)Nothing is being built, styled or painted.
  • DEFER — fetch b.js in parallelArrived second here, but that does not matter.
  • DEFER — execute a.js (document order)Order is guaranteed regardless of which download finished first.
  • DEFER — DOMContentLoadedFires after deferred scripts have run — they are allowed to prepare for it.
  • ASYNC (b wins) — fetch b.jsSmaller, or luckier, or a warmer connection.
  • ASYNC (b wins) — execute b.js MID-PARSEParser suspends here. If b.js needs a.js, this load throws.
  • ASYNC (a wins) — identical markup, other outcomeSame page, same code, different network. a.js executes first and everything works — which is why this ships.
  • MODULE — fetch its imports (second round trip)Graph depth becomes round trips on a cold connection; bundling collapses it (The Module Graph).
  • MODULE — DOMContentLoadedNon-async modules are in the same ordered group as deferred classics and also gate this event.

The two async rows are the lesson. Everything else differs in speed; those differ in whether the page works.

Choosing, without guessing

The decision is nearly always the same one, and the exceptions are narrow and identifiable. Start from defer or a plain module, and require a positive argument to move away from it.

The argument for async is "this script has no relationship with any other script, and no other script has a relationship with it". The argument for blocking is "the correct first paint depends on this". Nothing else qualifies, and "it felt faster" is not a measurement (Measure Before Optimising).

Which loading attribute

This tag needs an attribute. Which one, and what am I trading?

No attribute (parser-blocking)

when The first paint depends on the result — a theme class, a critical feature branch. Inline, tiny, no network, placed before the stylesheet.

cost Full parser suspension. On an external script that is a whole round trip during which nothing is built or painted (Why a Script Tag Stops the Parser).

`defer`

when The default for classic scripts. Anything that touches the DOM, anything with a dependency relationship, anything that other scripts rely on.

cost Execution is delayed until parsing completes, and several deferred scripts run back to back — a lumpier main thread than the same work spread out (Long Tasks).

`async`

when The script depends on nothing and nothing depends on it, and running earlier is genuinely worth losing every ordering guarantee. A standalone beacon is the honest example.

cost No order guarantee, no relationship to DOMContentLoaded, and a parser suspension at an unpredictable point mid-parse.

`type="module"`

when The default for new code. Deferred semantics, strict mode, real scoping and an explicit dependency graph, with one evaluation per resolved URL (ESM vs CommonJS).

cost CORS is mandatory for cross-origin modules, and an unbundled graph turns depth into round trips on a cold connection (The Module Graph).

`type="module" async`

when An independent module that should run as soon as its graph is ready, including an inline one — the only way to make an inline script non-blocking.

cost The same ordering loss as classic async, now applied to something that probably has imports and therefore probably has dependencies.

`preload` plus `defer`

when The script is important but referenced late in the document, so discovery is the bottleneck rather than execution.

cost Raises this script's priority against everything else in flight; doing it for many scripts spends the same budget in more places (Resource Hints).

How to build it

Most important first.

  • Default to defer for classic scripts and to plain type="module" for modules. Both give parallel fetch, document order, and execution before DOMContentLoaded.
  • Use async only for scripts that depend on nothing and that nothing depends on — a standalone analytics beacon is the honest example, and even that is often better deferred.
  • Never mix ordering models across a dependency. If B needs A, both must be in the same ordered group; one async in the pair removes the guarantee for the pair.
  • Prefer type="module" for new code: deferred semantics by default, strict mode by default, a real scope by default, and a real dependency graph instead of implicit globals (ESM vs CommonJS).
  • Pair defer with preload when a script is both important and late in the document. The fetch starts at head time; the execution still waits for parsing (Resource Hints).
  • Check what your bundler emits. Framework tooling usually emits type="module" with a nomodule classic fallback, and the ordering guarantees of the two paths are not identical (Bundlers Compared).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Behaviour attached by a deferred script is guaranteed to attach after the markup exists, which is exactly the ordering progressive enhancement needs: a real <button> or <a> works before the script arrives and gains behaviour after (Semantics Are Behaviour).
  • An async script that enhances a control may attach before or after the user reaches it. A keyboard user tabbing quickly into a widget that is not yet wired is the concrete failure (Keyboard Operability).
  • Deferred scripts running back to back after parsing is a long task, and a long task delays focus movement and screen-reader announcements just as it delays paint (Long Tasks).
  • Never let a script move focus on load. It is a common initialisation habit, it overrides where the browser placed focus, and it is disorienting for anyone not watching the screen (Focus Management).
  • If a control is genuinely inert until its script runs, say so in the markup — disabled, or aria-busy on the region — rather than leaving a control that looks operable and is not (Semantics Before ARIA).

What can go wrong

Failure modes
  • Two async scripts with a dependency between them: works locally on a warm cache, fails for a percentage of real users, and the error is undefined is not a function on a global.
  • defer on an inline script, which is silently ignored. The developer believes execution moved and it did not.
  • An async script that calls document.write after parsing has finished, which replaces the entire document with its output.
  • A cross-origin type="module" served without Access-Control-Allow-Origin, which fails to load entirely — while the same URL loaded as a classic script would have worked (CORS).
  • A DOMContentLoaded listener registered inside an async script that happened to arrive late: the event already fired, the listener never runs, and the page is half-initialised.
  • The mitigation failing: moving everything to defer and producing one long task after parsing, which delays interactivity even though nothing blocked the parser (Interaction Responsiveness).
What can arrive out of order
  • Two async scripts race, and the winner is whichever finishes downloading first. Any dependency between them is a bug that a fast network hides.
  • An async script races DOMContentLoaded. A listener it registers may be attached after the event already fired.
  • An async script races the parser itself: it can execute before the element it queries has been created, and whether it does depends on chunk arrival (Streaming HTML).
  • A dynamically injected script races whatever created it. If two code paths inject the same URL, the browser may fetch once and evaluate once for a module, but twice for a classic script.
Security
  • Module scripts are always fetched with CORS, which means a cross-origin module is subject to the origin's explicit permission. This is a real difference in exposure from classic scripts, which are fetched without it (CORS).
  • Under a strict CSP, nonce attributes apply to all these forms alike. A nonce must be per-response and unguessable; a static nonce is a policy with no effect (Content Security Policy).
  • integrity works on classic and module scripts and is the only thing standing between you and a modified file on a CDN you do not operate (Content-Hashed Assets).
  • Deferring a third-party script changes when it runs, not what it can do. It still executes with full page authority (Third-Party Scripts and the Supply Chain).
  • The browser enforces the ordering rules exactly. It enforces nothing about whether the script deserves to run at all — that is CSP, integrity and your dependency review (Dependency Security).
Misreads
  • "async and defer are both non-blocking, so they are interchangeable." They differ in the two things that matter: execution ordering and execution timing relative to parsing.
  • "defer means the script runs after the page has loaded." It runs after *parsing*, before DOMContentLoaded, and long before load (Streaming HTML).
  • "type="module" needs defer." It is deferred already. Adding defer is inert; adding async changes the semantics.
  • "Inline scripts can be deferred." Only module ones. defer on an inline classic script is ignored entirely.
  • "Injecting a script from JavaScript behaves like the tag I wrote." Dynamically created scripts default to async = true; the markup form does not.
  • "Non-blocking means cheap." All four forms execute on the main thread. Moving execution is not the same as removing it (What the Main Thread Owns).

Measuring it, and what changes in the field

How you would see this
  • The Network panel initiator column distinguishes parser-discovered from script-inserted requests, which tells you whether a script was found in the markup or injected later (Reading a Network Waterfall).
  • The Performance panel shows exactly where each script evaluated relative to parsing and to DOMContentLoaded. An async script executing mid-parse is unmistakable.
  • Reload with throttling and an empty cache, several times. Async ordering bugs only appear when arrival order changes, and that is the cheapest way to change it (Measure Before Optimising).
  • Field error tracking catches what local testing does not: an ordering-dependent TypeError reported by a small percentage of sessions is the signature of an async dependency (Frontend Error Tracking).
Slow device, slow network, large data, old tab
  • On a fast connection, async scripts almost always arrive in document order, so ordering bugs are invisible. On a slow or variable connection, arrival order is effectively random.
  • On a slow device, execution time dominates and the difference between defer and async matters less than the total amount of JavaScript (The Real Cost of JavaScript).
  • On a cold connection, a deep module graph turns into a chain of dependent requests, and each level of depth is a round trip. Bundling collapses the depth (The Module Graph).
  • On a repeat visit with a warm cache the fetch cost mostly disappears and execution order becomes far more stable — which is another reason a first-visit ordering bug hides.
What this costs
  • defer guarantees order at the cost of executing later, which delays everything a script enables. For code that is on the critical path to interactivity, that is a real cost.
  • async gets the earliest possible execution at the cost of every ordering guarantee, including its relationship to DOMContentLoaded. It is only safe for genuinely independent code.
  • Modules give real scoping and a real graph at the cost of CORS requirements, a deeper request graph before bundling, and slightly different semantics from the classic scripts around them.
  • Concentrating all execution after parsing produces a cleaner waterfall and a lumpier main thread. Splitting the work back up needs explicit yielding, not a loading attribute (Yielding and Scheduling).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe four orderings — parser-blocking, deferred, async, and module-deferred — are specified in the HTML Standard's script processing model and behave identically in Blink, Gecko and WebKit. Ordering differences you observe between browsers are arrival-order differences, not semantic ones.
  • SPEC-EVOLVINGThe surrounding platform keeps growing: import maps, import attributes and module worker semantics have all landed at different times in different engines, and support for the newer ones is uneven. Verify anything beyond plain type="module" against current support data rather than assuming it matches the core semantics described here.
  • FRAMEWORK-SPECIFICBuild tools decide these attributes for you and do not agree: Vite emits type="module" with a nomodule classic fallback, webpack and Rollup setups commonly emit deferred classic scripts, and framework meta-frameworks add their own preloads on top. Read the generated HTML rather than assuming the tool matched your intent.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

API Designhttp-semantics
Domains that do not exist yet
  • Compilers & Programming Languages — module resolution and linking. The browser performs specifier resolution, graph construction, instantiation and evaluation as distinct phases, which is the same pipeline a linker runs and the reason circular imports behave the way they do.
  • Distributed Systems — "order is not guaranteed" is the same statement here as it is for messages in a queue, and the same discipline applies: express dependencies explicitly rather than relying on an ordering the fast path happens to give you.