LEARNING OBJECTIVES ⌵
- Understand the 6 core architectural behaviors of
<script type="module">. - Explain how ES modules enforce automatic deferral, strict mode, and lexical module scoping.
- Use Top-Level
awaitwithin browser-native module scripts. - Implement
<script type="importmap">to resolve bare module specifiers without build-step bundlers.
📖 The Mental Model & Story (Intuitive Foundation)
In the early days of JavaScript, writing a complex web application was like having ten different authors scribbling on the exact same chalkboard at the same time. If Author A wrote var user = "Alice" and Author B wrote var user = "Bob", Author B erased Author A’s work. There were no boundaries; everything lived in one chaotic global room (window).
To fix this, developers invented complex bundling tools (Webpack, Browserify) to stitch thousands of files into giant monoliths before shipping them to the browser.
With Native ES Modules (type="module"), the browser became a modern library. Every module gets its own private, soundproof study room. What you declare in Room A stays in Room A unless you explicitly stamp it with export. Other rooms can cleanly import only the specific tools they need. The browser manages the dependency graph natively over HTTP/2 and HTTP/3 without requiring complex compilation toolchains.
Classic Global Script:
[ Script A: var total = 100 ] ──> [ window.total = 100 ] <── Overwritten!
[ Script B: var total = 200 ] ──> [ window.total = 200 ]
Native ES Module (<script type="module">):
[ Module A: const total = 100; export { total }; ] ──> Scoped to Module A
[ Module B: import { total } from './a.js'; ] ──> Clean, isolated reference (window.total is undefined)
Technical Deep Dive & Specifications
The 6 Core Behaviors of <script type="module">
When you add type="module" to a <script> tag, the browser switches from the classic script execution model to the ECMAScript Module (ESM) specification:
+---------------------------------------------------------------------------------------------------+
| THE 6 PILLARS OF NATIVE ES MODULES |
+---------------------------------------------------------------------------------------------------+
| 1. DEFERRED BY DEFAULT: Automatically behaves like `defer` (fetches in parallel, runs after DOM)|
| 2. STRICT MODE BY DEFAULT: `"use strict"` is permanently enabled; cannot be disabled. |
| 3. TOP-LEVEL SCOPE: Variables/functions are NOT attached to `window`. |
| 4. CORS ENFORCEMENT: Cross-origin modules MUST serve valid CORS headers; file:// is blocked. |
| 5. TOP-LEVEL AWAIT: You can `await` promises directly in module root without an async wrapper|
| 6. SINGLETON EXECUTION: A module is fetched and executed ONCE, even if imported 50 times. |
+---------------------------------------------------------------------------------------------------+
1. Automatic Deferral
You do not need to add the defer attribute to <script type="module">. The browser automatically fetches the module and its entire dependency graph in parallel in the background, executing them in document order after HTML parsing finishes and before DOMContentLoaded.
2. Top-Level await
In classic scripts, await was only valid inside an async function. In ES modules, top-level await is natively supported:
<script type="module">
// Top-level await is 100% valid!
const response = await fetch('/api/user');
const user = await response.json();
console.log('Logged in as:', user.name);
</script>
3. Module Singleton Execution
If moduleA.js and moduleB.js both contain import { db } from './database.js', the browser fetches database.js exactly once, executes it once, and shares the same live module export instance between both consumers.
Import Maps (<script type="importmap">)
Historically, browsers required relative or absolute URLs for imports:
// Valid in browsers:
import { format } from './utils/date.js';
import { Chart } from 'https://cdn.example.com/chart.js';
// INVALID in classic browser ESM (Bare Specifier):
import { format } from 'date-fns'; // ❌ TypeError: Failed to resolve module specifier
The Import Maps specification allows developers to declare alias mappings directly in HTML:
<script type="importmap">
{
"imports": {
"lodash": "https://cdn.jsdelivr.net/npm/[email protected]/lodash.js",
"services/": "/src/services/",
"@components/": "/src/ui/components/"
}
}
</script>
<script type="module">
// Now bare specifiers work natively in the browser!
import { debounce } from 'lodash';
import { AuthService } from 'services/auth.js';
</script>
⚠️ Strict Rule: An
<script type="importmap">element must appear before any<script type="module">tags in the HTML document.
💻 Interactive Code Playground
Starter Code
Line-by-Line Code Breakdown
- Lines 12–18 (
<script type="importmap">): Maps the bare specifier"formatter"to an in-memory ESM module exportingformatCurrency(). - Line 21 (
<script type="module">): Starts an ES module script. Automatically runs with deferred timing and strict mode. - Line 22 (
import { formatCurrency } from 'formatter'): Cleanly imports the function using the import map. - Lines 27–31 (
await new Promise(...)): Demonstrates Top-Levelawaitdirectly at the root level of the script. - Line 34 (
const accountBalance): Scoped strictly to this module. Line 43 logswindow.accountBalance is -> undefined, proving zero global namespace pollution.
Expected Browser Render Output
Banking Overview
[ Alex Mercer ]
Account Balance: $14250.75
(DevTools Console Output):
[Module Script]: Importing and executing...
[Module Script]: window.accountBalance is -> undefined🏋️ Hands-On Exercise
🎯 The Challenge: Build a Zero-Build Modular Application with Import Maps
You are building an administrative dashboard without any bundler (no Webpack, Vite, or Babel). You want to use native browser ES modules to structure your code into clean, decoupled files.
Instructions:
- Define a
<script type="importmap">in<head>that maps:"math-utils"to a module exporting acalculateDiscount(price, percent)function."dom-utils"to a module exporting arenderText(selector, text)function.
- Create a
<script type="module">in<head>that imports both utilities using their bare specifiers. - Use top-level
awaitto fetch a simulated product payload and render the discounted price into the DOM. - Verify that no variables pollute the global
windowobject.
🏁 Starter Code Sandbox
⚠️ Common Pitfalls
- Testing Modules via
file:///Protocol: Opening an HTML file with<script type="module">directly in a browser viafile:///C:/index.htmlwill fail with a CORS error. ES modules must be served overhttp://orhttps://(e.g. usingnpx serveor Live Server). - Placing
<script type="importmap">After Module Scripts: If an import map appears in the HTML after a module script that uses bare specifiers, the browser will throw an unrecoverableTypeError: Failed to resolve module specifier. - Omitting File Extensions in Relative Imports: In Node.js/Webpack, you can write
import { x } from './utils'. In native browser ESM (without an import map), you must include the full file extension:import { x } from './utils.js'.
💡 Pro Tips
- Preload Critical Module Subgraphs with
<link rel="modulepreload">: For deep module dependency graphs (e.g., Module A imports B, which imports C), use<link rel="modulepreload" href="/src/c.js">in<head>. This enables the browser to download and parse child modules in parallel before the parent finishes executing. - Using
asyncon Module Scripts: While module scripts are deferred by default, you can explicitly add<script type="module" async>. This causes the module to download its entire graph in parallel and execute immediately upon arrival (out-of-order), ideal for independent modern telemetry widgets.
📌 Key Takeaways
<script type="module">enables native ECMAScript Modules (ESM) in modern browsers without build tools.- Module scripts are deferred by default, execute in strict mode, and possess private lexical scope (no
windowleakage). - Native ES modules fully support Top-Level
awaitat the root of the file. <script type="importmap">maps bare package specifiers (e.g."react") to CDN or local URLs.- Import maps must be declared in
<head>before any module scripts. - --