Skip to main content

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:

  1. The browser starts downloading the script as soon as it encounters the <script defer> tag.
  2. HTML parsing continues without waiting. The script does not block the parser.
  3. The script executes after the HTML is fully parsed, but before the DOMContentLoaded event.
  4. Multiple defer scripts 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>
info

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:

  1. The browser starts downloading the script as soon as it encounters the tag.
  2. HTML parsing continues while the script downloads (same as defer).
  3. As soon as the script finishes downloading, parsing pauses and the script executes immediately.
  4. After execution, HTML parsing resumes.
  5. Multiple async scripts 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!");
}
warning

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​

FeatureNo attributedeferasync
Downloads during parsing?No (blocks)Yes (parallel)Yes (parallel)
Blocks HTML parsing?Yes (download + execution)NoOnly during execution
Execution timingImmediately when encounteredAfter parsing, before DOMContentLoadedAs soon as download completes
Execution order guaranteed?Yes (sequential)Yes (document order)No (download order)
DOM available at execution?Only elements above the scriptYes (full DOM)Unpredictable
Waits for DOMContentLoaded?N/AExecutes before itNo
Works on inline scripts?YesNo (ignored)No (ignored)
Best forLegacy, must-execute-immediately scriptsApplication scripts, libraries with dependenciesIndependent 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 orderOrder guaranteed
Like <script async> in HTMLLike <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​

BehaviorClassic ScriptModule Script
Default loadingBlocks parsingdefer (parallel, execute after parsing)
defer attributeChanges behaviorAlready default (attribute is redundant)
async attributeChanges behaviorChanges behavior (overrides default defer)
async on inlineIgnoredWorks
Execution count (same src)Every timeOnce
Strict modeOnly if "use strict"Always strict
Top-level thiswindowundefined
Top-level variablesGlobalModule-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.

tip

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 async by default.
  • Set script.async = false for ordered execution.
  • Use load and error events to track loading.
  • Ideal for conditional, lazy, or on-demand loading.

Module scripts (type="module"):

  • defer behavior by default (no attribute needed).
  • async attribute overrides to async behavior.
  • async works 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.