How to Load Scripts with async and defer in JavaScript
When the browser encounters a <script> tag while parsing HTML, it must make a decision: should it stop everything, download the script, execute it, and only then continue parsing the rest of the page? By default, the answer is yes, and this creates a significant performance problem. A single slow script can freeze the entire page rendering, leaving users staring at a blank or half-rendered screen.
The defer and async attributes solve this problem by changing how and when scripts are downloaded and executed. Understanding the difference between them, and knowing which one to use in each situation, directly impacts how fast your pages load and how quickly users can interact with them.
This guide explains the default blocking behavior, how defer and async change it, how dynamically created scripts behave, how module scripts fit in, and provides a clear decision framework for choosing the right loading strategy.
The Problem: Scripts Blocking HTML Parsingβ
By default, when the browser's HTML parser encounters a <script> tag, it stops parsing the HTML, downloads the script (if external), executes it, and only then resumes parsing. This is called parser-blocking behavior.
Visualizing the Default Behaviorβ
<!DOCTYPE html>
<html>
<head>
<title>My Page</title>
<script src="app.js"></script> <!-- Blocks here! -->
</head>
<body>
<h1>Hello</h1> <!-- Not parsed until app.js downloads AND executes -->
<p>Content</p>
</body>
</html>
Here is what happens step by step:
HTML Parsing: ββββββββ β BLOCKED β ββββββββββββ
β β
Script Download: β ββββββββββββββββ β
Script Execution: β βββ
β β
parser hits parser resumes
<script> after execution
The parser stops at the <script> tag, waits for the script to download (which depends on network speed), then waits for it to execute, and only then continues parsing the rest of the HTML. Nothing below the <script> tag exists in the DOM during this time.
Why This Is a Problemβ
<head>
<!-- These scripts block parsing of the entire <body> -->
<script src="https://cdn.example.com/framework.js"></script> <!-- 200ms download -->
<script src="https://cdn.example.com/utilities.js"></script> <!-- 150ms download -->
<script src="https://cdn.example.com/app.js"></script> <!-- 300ms download -->
</head>
<body>
<!-- User sees NOTHING until all three scripts download and execute -->
<!-- Total blocking time: 200 + 150 + 300 = 650ms+ (sequential!) -->
<h1>Welcome to My App</h1>
</body>
Each script blocks sequentially. The browser cannot start downloading the second script until the first has finished downloading and executing. The user sees a blank page for the entire duration.
The Traditional Workaround: Scripts at the Bottomβ
Before defer and async, the common workaround was placing all <script> tags at the end of <body>:
<body>
<h1>Welcome</h1>
<p>Content is visible immediately.</p>
<!-- Scripts at the bottom: HTML is already parsed -->
<script src="framework.js"></script>
<script src="app.js"></script>
</body>
This ensures the HTML is fully parsed and visible before scripts start downloading. However, it has a significant drawback: script downloads do not begin until the parser reaches the bottom of the page. On a large HTML document, this delays when scripts start loading.
The ideal solution would download scripts in parallel with HTML parsing, without blocking. That is exactly what defer and async provide.
defer: Download Parallel, Execute After Parsing (In Order)β
The defer attribute tells the browser: "Start downloading this script immediately, but do not execute it until the HTML is fully parsed. And if there are multiple deferred scripts, execute them in the order they appear in the HTML."
Syntaxβ
<script defer src="app.js"></script>
How defer Worksβ
HTML Parsing: ββββββββββββββββββββββββββββββββββββββββ β
β
Script Download: ββββββββββββββββ (parallel) β
Script Execution: βββ
β
HTML parsed, then
deferred scripts execute,
then DOMContentLoaded fires
Key behaviors:
- The browser starts downloading the script as soon as it encounters the
<script defer>tag. - HTML parsing continues without waiting. The script does not block the parser.
- The script executes after the HTML is fully parsed, but before the
DOMContentLoadedevent. - Multiple
deferscripts execute in document order (the order they appear in the HTML).
Multiple defer Scripts Maintain Orderβ
<head>
<script defer src="framework.js"></script> <!-- Downloads first, executes first -->
<script defer src="plugins.js"></script> <!-- Downloads in parallel, executes second -->
<script defer src="app.js"></script> <!-- Downloads in parallel, executes third -->
</head>
<body>
<h1>Visible immediately!</h1>
</body>
HTML Parsing: ββββββββββββββββββββββββββββββββββββββββββββ
β
framework.js: βββββββββββ (download) β
plugins.js: ββββββββ (download, parallel) β
app.js: βββββββββββββββ (download, parallel) β
β
Execution: ββ ββ βββ β
frameworkβ app β
plugins β
DOMContentLoaded
Even if plugins.js finishes downloading before framework.js, it waits. Execution order is guaranteed to match document order.
defer and DOMContentLoadedβ
Deferred scripts execute before DOMContentLoaded. This means your DOMContentLoaded handler can rely on deferred scripts having already run:
<head>
<script defer src="app.js"></script>
</head>
<body>
<script>
document.addEventListener("DOMContentLoaded", () => {
// app.js has ALREADY executed at this point
// Any globals or DOM modifications from app.js are available
console.log("DOMContentLoaded - app.js already ran");
});
</script>
</body>
The exact lifecycle order with defer:
1. HTML parsing completes
2. defer scripts execute (in document order)
3. DOMContentLoaded fires
defer and the DOMβ
Because deferred scripts execute after HTML parsing is complete, they can safely access any element in the document without needing DOMContentLoaded:
<head>
<script defer src="app.js"></script>
</head>
<body>
<div id="app">
<h1>My App</h1>
<button id="start-btn">Start</button>
</div>
</body>
// app.js - loaded with defer
// The entire DOM is available, even though this script is in <head>
let app = document.getElementById("app"); // β
Works
let btn = document.getElementById("start-btn"); // β
Works
btn.addEventListener("click", () => {
console.log("Button clicked!");
});
Without defer, this script in <head> would fail because <div id="app"> has not been parsed yet when the script executes.
defer Only Works with External Scriptsβ
The defer attribute is ignored on inline scripts (scripts without a src attribute):
<!-- defer IS applied (external script) -->
<script defer src="app.js"></script>
<!-- defer is IGNORED (inline script) -->
<script defer>
console.log("This runs immediately, defer has no effect!");
</script>
This is a common source of confusion. If you need to defer inline code, either move it into an external file or use DOMContentLoaded inside the inline script.
When to Use deferβ
defer is the best choice for most scripts because it provides the optimal loading behavior:
- Scripts download in parallel with HTML parsing (fast).
- Scripts execute in order (dependencies work correctly).
- Scripts execute after the DOM is ready (safe DOM access).
- Scripts do not block the user from seeing content (good UX).
<head>
<!-- Application scripts - order matters, DOM access needed -->
<script defer src="vendor/react.js"></script>
<script defer src="vendor/react-dom.js"></script>
<script defer src="app.js"></script>
</head>
async: Download Parallel, Execute Immediately (No Order)β
The async attribute tells the browser: "Start downloading this script immediately, and execute it as soon as it finishes downloading, regardless of whether HTML parsing is complete or other scripts are ready."
Syntaxβ
<script async src="analytics.js"></script>
How async Worksβ
HTML Parsing: ββββββββββββ βBLOCKEDβ βββββββββββββββββββ
β β
Script Download: ββββββββββββ β β
Script Execution: βββββββββ
β β
download execution
finishes completes,
parsing resumes
Key behaviors:
- The browser starts downloading the script as soon as it encounters the tag.
- HTML parsing continues while the script downloads (same as
defer). - As soon as the script finishes downloading, parsing pauses and the script executes immediately.
- After execution, HTML parsing resumes.
- Multiple
asyncscripts execute in download-completion order, which is unpredictable. Whichever script finishes downloading first runs first.
Unpredictable Execution Orderβ
<head>
<script async src="large-library.js"></script> <!-- 500KB, takes longer -->
<script async src="tiny-analytics.js"></script> <!-- 5KB, finishes fast -->
</head>
HTML Parsing: βββββββββ ββββ ββββββββββββββ ββββββ ββββββ
β β β β
large-library: ββββββββββββββββββββββββββββ β β
ββββββ (executes second!)
tiny-analytics: βββββββββ β β
ββββ (executes first!)
tiny-analytics.js finishes downloading first and executes first, even though large-library.js appears first in the HTML. If tiny-analytics.js depends on large-library.js, it will fail.
async and DOMContentLoadedβ
Unlike defer, async scripts do not wait for DOMContentLoaded and DOMContentLoaded does not wait for async scripts:
// async script - may run before or after DOMContentLoaded
console.log("Async script running");
console.log("readyState:", document.readyState); // Could be "loading" or "interactive"
The relationship is completely unpredictable:
- If the async script finishes downloading before HTML parsing completes, it runs before
DOMContentLoaded. - If the async script finishes downloading after HTML parsing completes, it runs after
DOMContentLoaded.
async and the DOMβ
Because async scripts can execute at any point during or after parsing, DOM elements may or may not exist when the script runs:
<head>
<script async src="app.js"></script>
</head>
<body>
<div id="app">Content</div>
</body>
// app.js - loaded with async
// This is UNRELIABLE - the DOM may not be ready yet!
let app = document.getElementById("app"); // β Might be null!
if (app) {
console.log("Found the app element");
} else {
console.log("Element not in DOM yet!");
}
Async scripts should not rely on the DOM being ready or on other scripts having executed. They should be fully independent and self-contained.
async Also Only Works with External Scriptsβ
Like defer, the async attribute is ignored on inline scripts:
<!-- async IS applied -->
<script async src="analytics.js"></script>
<!-- async is IGNORED -->
<script async>
console.log("Runs immediately, async has no effect on inline scripts");
</script>
When to Use asyncβ
Use async for scripts that are:
- Completely independent of the DOM and other scripts
- Do not depend on execution order
- Can run at any time without affecting the page
Typical async candidates:
<!-- Analytics - independent, does not modify the DOM -->
<script async src="https://www.google-analytics.com/analytics.js"></script>
<!-- Ad scripts - independent of page content -->
<script async src="https://ads.example.com/ad-loader.js"></script>
<!-- Error tracking - independent, self-contained -->
<script async src="https://cdn.sentry.io/sentry.js"></script>
<!-- Social media widgets - independent -->
<script async src="https://platform.twitter.com/widgets.js"></script>
defer vs. async: Side-by-Side Comparisonβ
Visual Comparisonβ
No attribute (default):
HTML: ββββββ β β ββββββββββββββββββ
Download: β ββββββββββ β
Execute: β ββ
parser parser
pauses resumes
defer:
HTML: ββββββββββββββββββββββββββββββββββββββββββ β
Download: ββββββββββββ (parallel, no blocking) β
Execute: ββ β
after parsing,
before DOMContentLoaded
async:
HTML: ββββββββββββββ β β ββββββββββββββββββββ
Download: ββββββββββββββ β β (parallel)
Execute: ββββββ (as soon as downloaded)
interrupts parsing
Feature Comparison Tableβ
| Feature | No attribute | defer | async |
|---|---|---|---|
| Downloads during parsing? | No (blocks) | Yes (parallel) | Yes (parallel) |
| Blocks HTML parsing? | Yes (download + execution) | No | Only during execution |
| Execution timing | Immediately when encountered | After parsing, before DOMContentLoaded | As soon as download completes |
| Execution order guaranteed? | Yes (sequential) | Yes (document order) | No (download order) |
| DOM available at execution? | Only elements above the script | Yes (full DOM) | Unpredictable |
Waits for DOMContentLoaded? | N/A | Executes before it | No |
| Works on inline scripts? | Yes | No (ignored) | No (ignored) |
| Best for | Legacy, must-execute-immediately scripts | Application scripts, libraries with dependencies | Independent third-party scripts |
Choosing Between defer and asyncβ
// QUESTION: Does the script depend on the DOM or other scripts?
// YES β use defer
// <script defer src="app.js"></script>
// - Needs DOM elements to exist
// - Depends on a framework loaded by another script
// - Must execute in a specific order relative to other scripts
// NO β use async
// <script async src="analytics.js"></script>
// - Runs independently
// - Does not access DOM elements
// - Does not depend on any other script
// - Does not care when it runs
Dynamic Scripts: document.createElement('script')β
You can create and insert script elements dynamically using JavaScript. This is commonly used for lazy loading, conditional loading, and loading third-party scripts.
Basic Dynamic Script Loadingβ
let script = document.createElement("script");
script.src = "module.js";
document.head.append(script); // Script starts downloading when appended to DOM
Dynamic Scripts Behave Like async by Defaultβ
When you create a script element and append it to the DOM, it behaves like an async script by default: it downloads in parallel and executes as soon as it finishes downloading, without preserving order relative to other dynamic scripts.
// These two scripts may execute in ANY order!
let script1 = document.createElement("script");
script1.src = "large-module.js"; // 500KB
document.head.append(script1);
let script2 = document.createElement("script");
script2.src = "small-module.js"; // 5KB
document.head.append(script2);
// small-module.js will likely execute first (downloads faster)
Making Dynamic Scripts Execute in Orderβ
To force dynamic scripts to execute in document order (like defer), set async to false:
function loadScriptsInOrder(urls) {
urls.forEach(url => {
let script = document.createElement("script");
script.src = url;
script.async = false; // Execute in order, like defer
document.head.append(script);
});
}
loadScriptsInOrder([
"vendor/framework.js", // Executes first
"vendor/plugins.js", // Executes second
"app.js" // Executes third
]);
With script.async = false, the scripts download in parallel but execute in the order they were appended to the DOM.
Detecting When a Dynamic Script Has Loadedβ
Dynamic scripts fire load and error events:
function loadScript(src) {
return new Promise((resolve, reject) => {
let script = document.createElement("script");
script.src = src;
script.addEventListener("load", () => {
resolve(script);
});
script.addEventListener("error", () => {
reject(new Error(`Failed to load script: ${src}`));
});
document.head.append(script);
});
}
// Usage
async function initApp() {
try {
await loadScript("https://cdn.example.com/library.js");
console.log("Library loaded successfully");
await loadScript("app.js");
console.log("App loaded successfully");
} catch (error) {
console.error(error.message);
}
}
initApp();
Loading Multiple Scripts in Parallel, Executing in Orderβ
async function loadScriptsParallel(urls) {
// Start all downloads simultaneously
let promises = urls.map(url => loadScript(url));
// Wait for all to finish (they execute as they load by default)
await Promise.all(promises);
console.log("All scripts loaded and executed");
}
// Or with ordered execution:
function loadScriptsOrdered(urls) {
return new Promise((resolve, reject) => {
let remaining = urls.length;
urls.forEach(url => {
let script = document.createElement("script");
script.src = url;
script.async = false; // Maintain order
script.addEventListener("load", () => {
remaining--;
if (remaining === 0) resolve();
});
script.addEventListener("error", () => {
reject(new Error(`Failed to load: ${url}`));
});
document.head.append(script);
});
});
}
Conditional Script Loadingβ
Dynamic scripts are perfect for loading code only when needed:
// Load a library only when the user needs it
document.getElementById("chart-btn").addEventListener("click", async () => {
// Only load the charting library when the user wants to see charts
if (!window.Chart) {
await loadScript("https://cdn.jsdelivr.net/npm/chart.js");
}
// Now Chart is available
let ctx = document.getElementById("myChart").getContext("2d");
new Chart(ctx, { /* config */ });
});
// Load different scripts based on browser capabilities
if (!("IntersectionObserver" in window)) {
loadScript("polyfills/intersection-observer.js");
}
// Load scripts based on user preferences
if (localStorage.getItem("theme") === "code-editor") {
loadScript("vendor/codemirror.js");
}
Dynamic Script Summaryβ
Default (createElement) | Explicitly Set |
|---|---|
async = true (downloads and executes immediately) | async = false (executes in append order) |
| No guaranteed order | Order guaranteed |
Like <script async> in HTML | Like <script defer> in HTML |
Module Scripts Are defer by Defaultβ
ES module scripts (<script type="module">) have their own loading behavior that differs from classic scripts.
Module Scripts Default to deferβ
<!-- Classic script: blocks parsing by default -->
<script src="classic.js"></script>
<!-- Module script: deferred by default (like adding defer) -->
<script type="module" src="module.js"></script>
Module scripts automatically:
- Download in parallel with HTML parsing (do not block).
- Execute after HTML parsing is complete.
- Execute in document order (relative to other module scripts).
- Execute before
DOMContentLoaded.
This is identical to defer behavior, but you do not need to write the defer attribute.
<head>
<!-- These module scripts behave like defer automatically -->
<script type="module" src="utils.js"></script>
<script type="module" src="app.js"></script>
</head>
<body>
<div id="app">Content</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
// Both module scripts have already executed
console.log("DOMContentLoaded");
});
</script>
</body>
</html>
Adding async to Module Scriptsβ
You can override the default defer behavior by adding async. This makes the module script download and execute as soon as possible, without waiting for parsing or other scripts:
<!-- Module with async: executes as soon as downloaded -->
<script type="module" async src="analytics-module.js"></script>
This is useful for independent module scripts that do not depend on the DOM or other modules.
Inline Module Scripts Support asyncβ
Unlike classic scripts, inline module scripts can use the async attribute:
<!-- Classic inline script: async is IGNORED -->
<script async>
console.log("async has no effect here");
</script>
<!-- Module inline script: async WORKS -->
<script type="module" async>
// This module and its imports load asynchronously
import { trackEvent } from "./analytics.js";
trackEvent("page_view");
</script>
Module Scripts Execute Only Onceβ
If the same module is imported multiple times (via import statements or multiple <script type="module"> tags with the same src), it is only downloaded and executed once:
<!-- The module is only fetched and executed ONCE -->
<script type="module" src="shared-module.js"></script>
<script type="module" src="shared-module.js"></script>
Module vs. Classic Script Behaviorβ
| Behavior | Classic Script | Module Script |
|---|---|---|
| Default loading | Blocks parsing | defer (parallel, execute after parsing) |
defer attribute | Changes behavior | Already default (attribute is redundant) |
async attribute | Changes behavior | Changes behavior (overrides default defer) |
async on inline | Ignored | Works |
| Execution count (same src) | Every time | Once |
| Strict mode | Only if "use strict" | Always strict |
Top-level this | window | undefined |
| Top-level variables | Global | Module-scoped |
Decision Flowchart: Choosing the Right Loading Strategyβ
Use this decision process to choose the right <script> loading strategy:
Does the script need to run inline (no src)?
βββ YES β Use a regular <script> (defer/async ignored on inline classic scripts)
β Or use <script type="module"> for inline module with async support
β
βββ NO (external script with src)
β
βββ Does it depend on the DOM or other scripts?
β β
β βββ YES β Use <script defer src="...">
β β β Downloads in parallel
β β β Executes after parsing
β β β Maintains order
β β β DOM is available
β β
β βββ NO (completely independent)
β β
β βββ Use <script async src="...">
β β Downloads in parallel
β β Executes ASAP
β β No order guarantee
β β DOM may not be ready
β
βββ Is it an ES module?
β
βββ Use <script type="module" src="...">
β defer by default
β Add async if independent
Practical Recommendationsβ
Here is a real-world HTML <head> showing the recommended loading strategy for different types of scripts:
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Application</title>
<!-- Critical inline styles (not a script, but affects script loading) -->
<style>/* critical CSS */</style>
<!-- Framework/library - needs order, needs DOM β defer -->
<script defer src="vendor/react.production.min.js"></script>
<script defer src="vendor/react-dom.production.min.js"></script>
<!-- Application code - depends on React, needs DOM β defer -->
<script defer src="app.bundle.js"></script>
<!-- Analytics - independent, no DOM needed β async -->
<script async src="https://www.googletagmanager.com/gtag/js?id=GA_ID"></script>
<!-- Error tracking - independent β async -->
<script async src="https://cdn.sentry.io/sdk.js"></script>
<!-- Module-based app - defer by default -->
<script type="module" src="modern-app.js"></script>
<!-- Polyfill - only loaded if needed, via dynamic script -->
<script>
if (!("IntersectionObserver" in window)) {
var s = document.createElement("script");
s.src = "polyfills/intersection-observer.js";
s.async = false; // Ensure it loads before app code
document.head.appendChild(s);
}
</script>
</head>
Common Mistakesβ
Mistake 1: Using async for scripts with dependencies
<!-- β WRONG: app.js depends on jQuery, but async doesn't guarantee order -->
<script async src="jquery.min.js"></script>
<script async src="app.js"></script>
<!-- app.js may execute before jQuery! "$ is not defined" -->
<!-- β
CORRECT: defer maintains order -->
<script defer src="jquery.min.js"></script>
<script defer src="app.js"></script>
Mistake 2: Expecting defer on inline scripts
<!-- β WRONG: defer is ignored on inline scripts -->
<script defer>
// This runs immediately, NOT after parsing!
let el = document.getElementById("app"); // May be null!
</script>
<!-- β
CORRECT: use DOMContentLoaded for inline scripts -->
<script>
document.addEventListener("DOMContentLoaded", () => {
let el = document.getElementById("app"); // β
Always works
});
</script>
Mistake 3: Putting defer scripts at the bottom of <body>
<!-- Suboptimal: defer at the bottom of body -->
<body>
<!-- ... lots of HTML ... -->
<script defer src="app.js"></script> <!-- Download starts late! -->
</body>
<!-- Better: defer in <head> - download starts immediately -->
<head>
<script defer src="app.js"></script> <!-- Downloads while HTML parses -->
</head>
<body>
<!-- ... lots of HTML ... -->
</body>
With defer in <head>, the script starts downloading as soon as the browser encounters the <head>. With defer at the bottom of <body>, the download does not start until the browser parses to the bottom. The execution timing is the same (after parsing), but the download starts earlier with <head> placement.
The best practice for most applications: Place all scripts in <head> with defer. This starts downloads as early as possible while ensuring execution happens after the DOM is ready and in the correct order. It gives you the best of both worlds: early download and safe execution timing.
<head>
<script defer src="vendor.js"></script>
<script defer src="app.js"></script>
</head>
This is faster than scripts at the bottom of <body> (downloads start earlier) and safer than scripts without defer in <head> (no parsing blocking).
Summaryβ
Script loading attributes control when scripts download and execute relative to HTML parsing.
Default (no attribute):
- Blocks HTML parsing during download and execution.
- Sequential: each script waits for the previous one.
- The slowest option. Avoid for external scripts in
<head>.
defer:
- Downloads in parallel with parsing (no blocking).
- Executes after HTML parsing is complete.
- Executes before
DOMContentLoaded. - Maintains document order (safe for dependencies).
- Full DOM is available at execution time.
- Ignored on inline scripts.
- Best for: application scripts, libraries with dependencies, any script that needs the DOM.
async:
- Downloads in parallel with parsing (no blocking).
- Executes immediately when download completes (may interrupt parsing).
- No guaranteed order between async scripts.
- DOM may or may not be ready.
- Does not wait for or block
DOMContentLoaded. - Ignored on inline scripts.
- Best for: independent third-party scripts (analytics, ads, error tracking).
Dynamic scripts (createElement):
- Behave like
asyncby default. - Set
script.async = falsefor ordered execution. - Use
loadanderrorevents to track loading. - Ideal for conditional, lazy, or on-demand loading.
Module scripts (type="module"):
deferbehavior by default (no attribute needed).asyncattribute overrides to async behavior.asyncworks on inline module scripts (unlike classic inline scripts).- Execute only once even if included multiple times.
- Always run in strict mode.
Best practice: Place scripts in <head> with defer for the fastest, safest loading. Use async only for truly independent scripts. Use dynamic loading for code that is not needed on every page load.