Building fast, secure, and maintainable front-ends demands an understanding of how scripts load and execute in the browser. This guide explains classic scripts, async, defer, and ES Modules—how they affect parsing and events, when to use each, and how to optimize performance and security.
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. The right strategy:
- Improves Core Web Vitals (especially FID/INP and LCP)
- Prevents race conditions and brittle dependencies
- Enables scalable architecture with import/export and code splitting
The four ways to load scripts
1) Classic (blocking)
- Blocks HTML parsing at the tag while downloading and executing.
- Execution order is the HTML order.
- Avoid unless you truly need immediate execution during parsing.
Example:
<!-- Avoid unless absolutely necessary -->
<script src="/js/critical-tiny.js"></script>
2) async (classic)
- Downloads in parallel; executes as soon as it’s ready.
- Execution order is non-deterministic (download-completion order).
- Great for independent scripts: analytics, ads, beacons.
Example:
<script async src="https://cdn.example.com/analytics.js"></script>
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.
Example:
<script defer src="/js/vendor.js"></script>
<script defer src="/js/app.js"></script>
4) ES Modules (type="module")
- Defer-like by default for entry scripts, plus dependency-aware loading.
- Scoped (no accidental globals), strict mode, import/export, dynamic import(), top-level await, import maps.
- Best default for modern apps.
Example:
<script type="module" src="/js/main.js"></script>
How parsing, execution, and events interact
- Classic (blocking): Stops HTML parsing at the tag. Runs immediately.
- async: Doesn’t block parsing; executes ASAP, possibly before parsing finishes.
- defer: Doesn’t block parsing; executes after parsing completes and before DOMContentLoaded.
- module: Entry behaves like defer, but the browser first resolves and runs dependencies in graph order.
Event timing:
- DOMContentLoaded fires after deferred and module entries execute, and after any async scripts that started before DCL complete.
- load fires after all resources (images, CSS, etc.) have finished loading.
Choosing the right approach
- Use type="module" for application code by default.
- Use defer for classic scripts when modules aren’t an option.
- Use async for independent third-party scripts that don’t require order or DOM synchronization.
- 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
<script type="module">
import { init } from '/js/app.js';
init();
</script>
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
<script type="importmap">
{
"imports": {
"lodash": "/vendor/lodash-es/lodash.js",
"@app/": "/static/app/"
}
}
</script>
<script type="module">
import _ from 'lodash';
import util from '@app/util.js';
console.log(_.chunk([1,2,3,4], 2), util);
</script>
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
<!-- Establish early connections -->
<link rel="preconnect" href="https://cdn.example.com">
<link rel="dns-prefetch" href="https://cdn.example.com">
<!-- Preload a classic script -->
<link rel="preload" as="script" href="/js/app.js">
<!-- Preload modules (and module chunks) -->
<link rel="modulepreload" href="/js/main.js">
<link rel="modulepreload" href="/js/chunk-abc123.js">
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.
- Dynamic import() for large or rarely used features to keep initial payload small.
Execution order and examples
async vs defer
<!-- Defer: predictable order is A then B -->
<script defer src="A.js"></script>
<script defer src="B.js"></script>
<!-- Async: non-deterministic order (race between C and D) -->
<script async src="C.js"></script>
<script async src="D.js"></script>
Modules and dependency order
<script type="module" src="entry.js"></script>
// 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
<script defer src="/js/app.js"></script>
// app.js
document.querySelector('#btn').addEventListener('click', () => {
console.log('clicked');
});
Async script that must wait for the DOM
<script async src="/js/metrics.js"></script>
// 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:// for modules; use a local HTTP(S) server.
CSP and integrity
<!-- Strict CSP example (adjust to your policy) -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'self'">
<!-- SRI for third-party classic scripts -->
<script defer src="https://cdn.example.com/lib.js"
integrity="sha384-BASE64HASH"
crossorigin="anonymous"></script>
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
// ❌ import { x } from './utils';
import { x } from './utils.js'; // ✅
- Import map placement
<!-- ❌ import map after module (too late) -->
<script type="module" src="/js/main.js"></script>
<script type="importmap">...</script>
<!-- ✅ import map before any module that uses it -->
<script type="importmap">...</script>
<script type="module" src="/js/main.js"></script>
- Assuming async preserves order. It doesn’t—don’t chain logic across multiple async scripts expecting sequence.
- Top-level await in a widely shared module can delay the entire app. Prefer lazy loading where practical.
Copy‑paste templates
Modern app with modules + legacy fallback
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Modern Module App</title>
<link rel="modulepreload" href="/js/main.js">
</head>
<body>
<div id="app"></div>
<script type="importmap">
{
"imports": {
"@app/": "/js/"
}
}
</script>
<script type="module" src="/js/main.js"></script>
<script nomodule src="/js/legacy-bundle.js"></script>
</body>
</html>
Classic with defer
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Classic Defer</title>
</head>
<body>
<button id="btn">Click</button>
<script defer src="/js/vendor.js"></script>
<script defer src="/js/app.js"></script>
</body>
</html>
Async for independent third-party
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Async Example</title>
<script async src="https://cdn.example.com/analytics.js"></script>
</head>
<body>
<!-- page content -->
</body>
</html>
Final takeaways
- Prefer type="module" for modern development: scalable architecture, safer scoping, and built-in code splitting.
- Use defer for classic scripts when modules aren’t feasible.
- Use async exclusively 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.
Top comments (1)
Defer or type="module" almost always beats async for INP/LCP-use async only for truly independent 3P tags and kick them to idle/load so they don’t stomp the main thread. Prime your ESM graph with modulepreload, and dodge top‑level await in shared modules by exporting promises and awaiting at the edges. For third‑party stuff, play it safe: CSP with nonces/SRI, consent gates, sandbox if sketchy-and never trust async order, orchestrate explicitly. Push CPU‑heavy work to module workers and verify wins with RUM plus PerformanceObserver so you can call out the real culprits.