The Complete Guide to <script> Loading in Modern Web Apps: async, defer, and ES Modules
Source: Dev.to
Table of Contents
- Why script‑loading strategy matters
- The four ways to load scripts
- How parsing, execution, and events interact
- Choosing the right approach
- ES Modules deep dive
- Performance techniques
- DOM timing patterns
- Security and compliance
- Common pitfalls
- Copy‑paste templates
- Final takeaways
Why script‑loading strategy matters
Script tags can block HTML parsing, delay interactivity, and alter event timing. Choosing the right strategy:
- Improves Core Web Vitals (especially FID/INP and LCP)
- Prevents race conditions and brittle dependencies
- Enables scalable architecture with
import/exportand code‑splitting
The four ways to load scripts
1️⃣ Classic (blocking)
- Blocks HTML parsing at the tag while downloading and executing.
- Execution order follows the HTML order.
- Use only when you truly need immediate execution during parsing.
2️⃣ async (classic)
- Downloads in parallel; executes as soon as it’s ready.
- Execution order is non‑deterministic (download‑completion order).
- Ideal for independent scripts: analytics, ads, beacons.
3️⃣ defer (classic)
- Downloads in parallel; executes after the HTML is fully parsed and before
DOMContentLoaded. - Preserves HTML order.
- Best classic choice for app code when you can’t use modules.
4️⃣ ES Modules (type="module")
- Defer‑like by default for entry scripts, plus dependency‑aware loading.
- Scoped (no accidental globals), strict mode,
import/export, dynamicimport(), top‑levelawait, import maps. - The default for modern apps.
How parsing, execution, and events interact
| Loading type | Parsing impact | Execution timing | Order guarantee |
|---|---|---|---|
| Classic (blocking) | Stops HTML parsing at the tag | Immediately after download | HTML order |
async | Does not block parsing | As soon as the script finishes downloading | None (download order) |
defer | Does not block parsing | After parsing completes, before DOMContentLoaded | HTML order |
| Module (entry) | Does not block parsing | After parsing completes, before DOMContentLoaded (defer‑like) | Dependency graph order |
Event timing
DOMContentLoadedfires after all deferred and module entry scripts have executed, and after anyasyncscripts that started beforeDOMContentLoadedfinish.loadfires after all resources (images, CSS, etc.) have finished loading.
Choosing the right approach
- Application code →
type="module"(default). - Classic scripts when modules aren’t an option →
defer. - Independent third‑party scripts →
async. - Avoid blocking classic scripts—they stall rendering and interactivity.
ES Modules deep dive
Imports and exports
// app.js
export function init() {
console.log('App started');
}
// main.js
import { init } from './app.js';
init();
Inline module
import { init } from '/js/app.js';
init();
Dynamic import() for on‑demand code
const btn = document.querySelector('#showChart');
btn.addEventListener('click', async () => {
const { renderChart } = await import('./chart.js');
renderChart();
});
Top‑level await
// config.js
export const cfg = await fetch('/api/config')
.then(r => r.json());
// main.js
import { cfg } from './config.js';
console.log('Config:', cfg);
Module scope and strict mode
// my-module.js
const secret = 42; // Not on window
export const api = { hello: 'world' };
// Expose globally only if you must:
window.myApi = { doSomething() { /* … */ } };
Import maps for bare specifiers
{
"imports": {
"lodash": "/vendor/lodash-es/lodash.js",
"@app/": "/static/app/"
}
}
import _ from 'lodash';
import util from '@app/util.js';
console.log(_.chunk([1,2,3,4], 2), util);
Modules in Web Workers
// main thread
const worker = new Worker('/js/worker.js', { type: 'module' });
// worker.js
import { heavyCompute } from './heavy.js';
self.onmessage = e => postMessage(heavyCompute(e.data));
Performance techniques
Use resource hints thoughtfully
Caching and bundling
- Use long‑term caching with content hashes for production assets.
- Even with HTTP/2/3, bundling/chunking reduces request overhead and improves cache‑hit rates.
- Leverage dynamic
import()for lazy‑loading large, rarely‑used modules.
Code‑splitting & lazy loading
// router.js – load route component only when needed
export async function loadDashboard() {
const { Dashboard } = await import('./pages/dashboard.js');
return new Dashboard();
}
Minify & compress
- Serve JavaScript as gzip or Brotli (
br). - Use tools like esbuild, SWC, or Terser for minification.
Avoid duplicate work
- Do not load the same script both as classic and as a module.
- Consolidate third‑party libraries into a single bundle when order matters.
DOM timing patterns
| Pattern | When to use | Example |
|---|---|---|
DOMContentLoaded listener | Code that needs the full DOM but not external resources | document.addEventListener('DOMContentLoaded', initApp); |
load listener | Code that depends on images, fonts, or other media | window.addEventListener('load', () => { /* layout calculations */ }); |
requestIdleCallback | Non‑critical work that can be deferred until the main thread is idle | requestIdleCallback(() => { prefetchData(); }); |
setTimeout(..., 0) | Micro‑task queue flush; rarely needed with modern async patterns | setTimeout(() => console.log('next tick'), 0); |
Security and compliance
type="module"scripts are automatically strict mode and have module‑scope (no accidental globals).- Use Subresource Integrity (SRI) for third‑party scripts:
- Set Content‑Security‑Policy (
script-src) to whitelist trusted origins and disallowunsafe-inline. - Prefer
nonceor hash based CSP for inline modules when necessary.
Common pitfalls
- Mixing
asyncanddeferon the same script – the last attribute wins; avoid confusion. - Relying on global variables from modules – modules are scoped; expose only what you need via
export. - Forgetting to add
type="module"to dynamically created<script>elements – they default to classic. - Using
document.writeafter a module has started loading – can abort module loading in some browsers. - Assuming
modulepreloadworks in all browsers – provide a fallback<script>for older browsers.
Copy‑paste templates
Classic blocking (rarely needed)
Async third‑party
Defer classic app bundle
ES Module entry point
Inline module with CSP nonce
import { init } from '/js/app.js';
init();
Import map (HTML5)
{
"imports": {
"react": "/libs/react/react.production.min.js",
"react-dom": "/libs/react/react-dom.production.min.js"
}
}
Final takeaways
- Default to ES Modules for any new code; they give you defer‑like loading, strict mode, and a clean dependency graph.
- Use
deferonly when you must support classic scripts in environments that don’t understand modules. - Reserve
asyncfor truly independent third‑party resources that don’t need DOM readiness or ordering. - Pair the right loading strategy with resource hints, caching, and CSP/SRI to maximise performance and security.
By consciously selecting the appropriate script‑loading technique, you’ll deliver faster, more reliable, and more secure web experiences.
Execution Order and Examples
async vs defer
Modules and Dependency Order
// entry.js
import './a.js'; // a.js imports b.js
console.log('entry');
// a.js
import './b.js';
console.log('a');
// b.js
console.log('b');
Execution order: b → a → entry
DOM‑Timing Patterns
Safe DOM access with defer / modules
// app.js
document.querySelector('#btn').addEventListener('click', () => {
console.log('clicked');
});
Async script that must wait for the DOM
// metrics.js
window.addEventListener('DOMContentLoaded', () => {
// Safe to query the DOM
});
Security and Compliance
Headers, CORS, MIME
- Serve JavaScript with the correct MIME type:
application/javascript. - ES Modules obey CORS; cross‑origin imports require proper CORS headers.
- Avoid
file://URLs for modules; use a local HTTP(S) server.
CSP and Subresource Integrity (SRI)
Enterprise note: Before adding third‑party CDNs, libraries, or analytics, verify they align with your organization’s security, privacy, and compliance guidelines.
Common Pitfalls
Missing .js extension in browser imports
// ❌ Wrong
import { x } from './utils';
// ✅ Correct
import { x } from './utils.js';
Import‑map placement
…
…
Other gotchas
- Assuming
asyncpreserves order. It doesn’t—don’t chain logic across multiple async scripts expecting sequence. - A top‑level
awaitin a widely shared module can delay the entire app. Prefer lazy loading where practical.
Copy‑Paste Templates
Modern app with modules + legacy fallback
Modern Module App
{
"imports": {
"@app/": "/js/"
}
}
Classic with defer
Classic Defer
Click
Async for independent third‑party script
Async Example
Final Takeaways
- Prefer
type="module"for modern development: scalable architecture, safer scoping, and built‑in code splitting. - Use
deferfor classic scripts when modules aren’t feasible. - Use
asyncexclusively for independent, order‑agnostic scripts. - Mind event timing, caching, and security (CSP, SRI, CORS).
- Employ
modulepreload, dynamic imports, and import maps as your app grows.
If you plan to use third‑party CDNs or libraries, ensure they conform to your organization’s internal security and compliance requirements.