Loading Scripts: defer, async and type=module
Where a script tag sits and which attribute it carries decides whether your page paints in half a second or four. Three loading behaviours, and when each is correct.
-
HTML Basics
- What is HTML: The Structure Layer of Every Web Page
- HTML Document Structure: DOCTYPE, html, head and body
- Elements, Tags and Attributes: The Vocabulary of HTML
- HTML Comments: Notes That Ship With Your Code
- Block Level and Inline Elements
- Writing and Running Your First HTML Page
- How a Browser Turns Markup Into a Page
- Text and Formatting
- Links and Navigation
- Images and Media
- Lists
- Tables
-
Forms
- Form Structure: form, action and method
- Input Types: Text, Email, Number, Date and the Rest
- Labels: The Most Important Element in a Form
- Checkboxes, Radio Buttons and Grouping
- select, option, optgroup and datalist
- textarea, File Uploads and Hidden Fields
- Buttons: submit, reset and button
- Built In Form Validation
- GET or POST: What Happens When a Form Is Submitted
- Semantic HTML
- HTML5 Features
- Head and Metadata
- HTML with CSS
- HTML with JavaScript
- Accessibility
-
HTML SEO
- How Google Works: Crawling, Indexing and Ranking
- SEO Friendly HTML Structure
- Titles and Descriptions That Earn Clicks
- Headings and Content Structure for Search
- Internal Linking and Anchor Text
- robots.txt and XML Sitemaps
- Canonical URLs and Duplicate Content
- Structured Data and JSON-LD
- Image SEO
- Core Web Vitals and Mobile Friendliness
- DevTools and Debugging
- Editor Productivity
- HTML Best Practices
- HTML Projects
- Advanced Projects
- Practice and Exams
Concept
A script element can stop the browser dead. Understanding exactly when it does is the difference between a page that appears instantly and one that shows a blank screen while a file downloads.
The three behaviours
Blocking - no attribute
<script src="/scripts/app.js"></script>The parser stops. It downloads the file, runs it, and only then continues building the DOM. Everything below that line is unparsed and therefore unpainted for the whole duration.
In the head this is the single most damaging thing you can do to perceived performance.
defer
<script src="/scripts/app.js" defer></script>Downloads in parallel with parsing, and runs after the document is fully parsed but before DOMContentLoaded. Multiple deferred scripts run in the order they appear in the markup.
This is the right default for almost everything.
async
<script src="/scripts/analytics.js" async></script>Downloads in parallel and runs the moment it arrives, interrupting the parser at whatever point that happens to be. Order is not guaranteed - the fastest download runs first.
Only correct for independent scripts that do not touch the DOM and do not depend on anything else. Analytics is the standard example.
Comparison
| Blocks parsing | Runs when | Order kept | |
|---|---|---|---|
| No attribute | Yes | Immediately | Yes |
defer | No | After parsing | Yes |
async | Only while executing | On arrival | No |
type="module" | No | After parsing | Yes |
type="module"
<script type="module" src="/scripts/main.js"></script>// main.js
import { renderCourses } from "./courses.js";
renderCourses(data);Modules bring several behaviours at once:
- Deferred by default. No attribute needed.
- Strict mode always.
- Their own scope. Top level variables do not leak onto
window. - Executed once, even if imported many times.
- Fetched with CORS rules, so a cross origin module needs the right headers.
- Do not work over
file://, which is a common first surprise. Serve overlocalhost.
<!-- modern browsers run this and ignore the next line -->
<script type="module" src="/scripts/main.js"></script>
<!-- module aware browsers ignore nomodule; older ones run it -->
<script nomodule src="/scripts/legacy.js" defer></script>Where to put the tag
<head>
<script src="/scripts/app.js" defer></script>
</head>With defer, the head is the best place: the download starts as early as possible and execution still waits for a complete DOM. The old advice to put scripts at the end of the body was a workaround for not having defer, and it delays the download.
Inline scripts
<script>
document.documentElement.classList.add("js");
</script>An inline script always blocks and defer and async have no effect on it. Keep any inline script tiny - a class on the root element, a theme applied before paint - and put everything else in a file.
The one legitimate case for a blocking inline script is preventing a flash: reading a stored theme and applying it before the first paint.
<script>
try {
const theme = localStorage.getItem("theme");
if (theme) document.documentElement.dataset.theme = theme;
} catch (e) {}
</script>Loading order in practice
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Design course</title>
<link rel="stylesheet" href="/styles/site.css">
<script src="/scripts/app.js" defer></script>
<script src="/scripts/analytics.js" async></script>
</head>The stylesheet blocks painting - which is correct. The application script downloads alongside parsing and runs when the DOM is ready. Analytics runs whenever it arrives and affects nothing.
Waiting for the DOM
// unnecessary in a deferred script - the DOM is already complete
document.addEventListener("DOMContentLoaded", () => { ... });
// needed only in a blocking or async script that touches the DOM
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}| Event | Fires when |
|---|---|
DOMContentLoaded | The DOM is built. Images may still be loading. |
load | Everything has finished, including images and iframes. |
Most code wants DOMContentLoaded, or nothing at all when using defer.
Third party scripts
<script src="https://cdn.example.com/widget.js"
async
crossorigin="anonymous"
integrity="sha384-..."></script>integrity is a subresource integrity hash. The browser verifies the downloaded file against it and refuses to run anything that does not match, which protects against a compromised CDN serving altered code. It requires crossorigin to be set.
Every third party script is a security and performance decision. It runs with full access to your page: it can read the DOM, read cookies available to script, and modify anything. Add each one deliberately.
Important rules
deferandasyncapply only to external scripts, never to inline ones.deferpreserves order;asyncdoes not.- Modules are deferred automatically.
- Modules do not work from
file://. - A blocking script in the head delays the first paint by its entire download and execution time.
- A script cannot reach an element that has not been parsed yet.
Common mistakes
- A blocking script in the head, then blaming the server for slow rendering.
- Adding
deferto an inline script, where it does nothing. - Using
asyncfor scripts that depend on each other, producing random failures. - Opening a module page from the file system and seeing a CORS error.
- Wrapping deferred code in
DOMContentLoadedunnecessarily. - Loading a large library for one small feature.
- Adding third party scripts with no integrity hash and no review.
Best practices
deferon every script unless there is a specific reason otherwise.asynconly for genuinely independent scripts.- Prefer
type="module"for new work. - Keep inline scripts to a few lines.
- Put script tags in the head with
defer, not at the end of the body. - Add
integrityandcrossoriginto third party scripts. - Measure with the Network panel rather than guessing.
Practice
- Put a script in the head that loops for two seconds before the body, and describe what the reader sees. Add
deferand compare. - Load three
asyncscripts that log their names, reload several times, and observe the order. - Open a page using
type="module"from the file system and read the console error, then serve it over localhost. - Compare first paint in the Network panel with a blocking script and with a deferred one.