The HTML parser, script fetcher, and JavaScript engine can work concurrently,
but execution constraints differ by script type and attributes. Choose a mode
from the dependency graph and readiness contract rather than adding async or
defer only as a performance hint.
Understand Parser-Blocking Classic Scripts #
A classic external script without async or defer blocks the parser at its
element. The browser fetches and executes the script before parsing subsequent
markup:
<script src="/library.js"></script>
<script src="/application.js"></script>The second script can read globals created by the first because document order is execution order. An inline classic script also runs immediately at its parser position, so it can access only the DOM parsed up to that point.
Parser blocking can be intentional when a script must call document.write or
establish state before later markup is interpreted. Application code should
avoid those dependencies because network latency then delays both DOM
construction and first rendering.
Defer Ordered Classic Scripts #
defer applies to external classic scripts. Fetching proceeds in parallel with
HTML parsing, then deferred scripts execute in document order after parsing and
before DOMContentLoaded:
<script defer src="/library.js"></script>
<script defer src="/application.js"></script>The second script keeps its dependency on the first without blocking the
parser. The defer attribute has no effect on an inline classic script; use a
module or an explicit readiness callback instead.
A deferred script can still delay DOMContentLoaded through slow fetching or
synchronous execution. Stylesheets needed by the script can also postpone its
execution because script code may depend on computed style.
Load Independent Scripts Asynchronously #
An external classic script with async executes as soon as it is ready. Fetch
does not block parsing, but execution pauses the parser if the response arrives
while parsing is still in progress:
<script async src="https://analytics.example/sdk.js"></script>Two async scripts have no execution-order guarantee. They must not depend on
one another, on a complete DOM, or on running before DOMContentLoaded.
DOMContentLoaded does not wait for them, while the window load event waits
for scripts that are part of the document’s outstanding load work.
Use an explicit callback or promise supplied by the integration when later code depends on async initialization. Observing that a global exists on one network does not establish a valid ordering contract.
Load ES Modules as Dependency Graphs #
type="module" changes both loading and execution:
<script type="module" src="/main.js"></script>Module scripts are deferred by default, including inline modules. Static
import declarations form a graph whose dependencies are fetched before a
module evaluates. Modules use strict mode, have their own top-level scope, and
are evaluated once for each resolved module identity rather than once per
<script> element.
Module fetches use CORS. A cross-origin module and every cross-origin dependency
must return an appropriate Access-Control-Allow-Origin response, and a
JavaScript-compatible media type is required. Classic-script behavior is not a
fallback for a failed module fetch.
Adding async to a module script lets its graph evaluate as soon as it is
ready, removing normal document-order behavior:
<script type="module" async src="/independent-widget.js"></script>Top-level await suspends evaluation of modules that depend on the awaiting
module. Do not treat DOM readiness as proof that application initialization
has completed; export or publish an initialization promise when consumers must
wait for asynchronous module work.
Insert Scripts Dynamically #
A script element created by JavaScript is force-async by default:
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.addEventListener("load", resolve, { once: true });
script.addEventListener("error", reject, { once: true });
document.head.append(script);
});
}The returned promise defines a usable completion boundary:
await loadScript("/library.js");
await loadScript("/application.js");A loader can set script.async = false before insertion to place dynamically
inserted classic scripts in an ordered queue. Explicit promise sequencing is
clearer when failures need to stop dependent loads. Dynamic import() is the
corresponding module mechanism and resolves only after the requested module
graph evaluates.
Dynamically adding a script after DOMContentLoaded does not fire that event
again. Adding load-delaying work before the window load event can postpone
load; adding it afterward cannot replay the event.
Select the Correct Readiness Event #
DOMContentLoaded means that parsing is complete and parser-deferred scripts
have run. It does not mean that images, async scripts, dynamically started
application work, or every custom element is ready:
function start() {
// The parsed DOM is available here.
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
start();
}The readyState branch matters for modules and dynamically loaded code that
may begin after the event. Use load only when layout or behavior requires
load-complete resources such as images or frames. Prefer resource-specific
promises such as image.decode(), document.fonts.ready, or a module’s own
initialization promise when only one dependency matters.
Parser-blocking scripts provide immediate ordering, defer provides ordered
post-parse execution, async provides independent opportunistic execution,
and modules provide a CORS-checked dependency graph. Matching the mechanism to
the dependency contract removes timing assumptions that disappear under a
different cache or network.
Comments
Read or comment on GitHub (sign-in required to comment)