Introduction to JavaScript Modules
As JavaScript applications grew from simple page enhancements to complex, full-scale applications, managing code across thousands of lines in a single file became impossible. Developers needed a way to split code into separate files, each with its own scope and a clear interface for sharing functionality. After years of community-driven solutions and competing standards, JavaScript finally received a native module system: ES Modules.
This guide traces the history of how JavaScript arrived at its current module system, explains what a module actually is and how it differs from a regular script, covers the core features and behaviors that make modules special, and introduces the metadata capabilities available through import.meta. By the end, you will have a solid foundation for understanding how modern JavaScript organizes code.
Why Modules? The History
JavaScript was created in 1995 for adding small interactive behaviors to web pages. There was no concept of modules because scripts were tiny. As applications grew, the lack of a module system became a serious problem.
The Early Days: Global Scripts
In the beginning, you loaded JavaScript with multiple <script> tags. Every script shared the same global scope:
<!-- Every variable and function lives in the global scope -->
<script src="utils.js"></script>
<script src="validation.js"></script>
<script src="api.js"></script>
<script src="app.js"></script>
// utils.js
var formatDate = function(date) { /* ... */ };
var capitalize = function(str) { /* ... */ };
// validation.js
var validate = function(data) { /* ... */ };
var formatDate = function(d) { /* ... */ }; // Oops! Overwrites utils.js's formatDate!
// app.js
formatDate(new Date()); // Which formatDate? The one from validation.js (silently broken)
Problems with this approach:
- Name collisions: Any script can overwrite variables from any other script
- No encapsulation: Internal helper functions pollute the global namespace
- Implicit dependencies: There is no way to declare that
app.jsneedsutils.js - Load order matters: Scripts must be loaded in the right order or things break silently
- No way to load on demand: Everything loads upfront, even if not needed
The IIFE Pattern (2000s)
Developers discovered they could use Immediately Invoked Function Expressions to create private scopes:
// utils.js: using IIFE to create a "module"
var Utils = (function() {
// Private_ not accessible from outside
function padZero(n) {
return n < 10 ? "0" + n : String(n);
}
// Public: returned as the module interface
return {
formatDate: function(date) {
return date.getFullYear() + "-" +
padZero(date.getMonth() + 1) + "-" +
padZero(date.getDate());
},
capitalize: function(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
};
})();
// app.js
console.log(Utils.formatDate(new Date())); // "2024-01-15"
console.log(Utils.capitalize("hello")); // "Hello"
// padZero is private (not accessible)
This was a significant improvement. Each "module" exposed only its public API through a single global variable. Internal helpers remained private. But IIFEs still had limitations:
- Dependencies were implicit (you had to know to load
utils.jsbeforeapp.js) - You still had one global variable per module
- No standard way to declare or resolve dependencies
CommonJS (2009, Node.js)
When Node.js was created, it needed a proper module system. CommonJS became the standard for server-side JavaScript:
// math.js
function add(a, b) { return a + b; }
function multiply(a, b) { return a * b; }
module.exports = { add, multiply };
// app.js
const { add, multiply } = require("./math");
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
CommonJS was a breakthrough: explicit dependencies, private scope per file, and a clear export mechanism. However, it was designed for servers where files are loaded from disk synchronously. The synchronous require() call blocks execution until the file is loaded, which is unacceptable in browsers where files are loaded over the network.
AMD (Asynchronous Module Definition, 2010s)
AMD, implemented by libraries like RequireJS, solved the async loading problem for browsers:
// math.js
define("math", [], function() {
return {
add: function(a, b) { return a + b; },
multiply: function(a, b) { return a * b; }
};
});
// app.js
define("app", ["math"], function(math) {
console.log(math.add(2, 3)); // 5
});
AMD worked in browsers but was verbose and had a completely different API from CommonJS. This meant code could not be easily shared between server and browser.
UMD (Universal Module Definition)
UMD attempted to bridge CommonJS and AMD with a wrapper that detected the environment:
(function(root, factory) {
if (typeof define === "function" && define.amd) {
define([], factory); // AMD
} else if (typeof module === "object" && module.exports) {
module.exports = factory(); // CommonJS
} else {
root.MyModule = factory(); // Browser globals
}
})(typeof self !== "undefined" ? self : this, function() {
return {
add: function(a, b) { return a + b; }
};
});
UMD worked everywhere but was ugly and boilerplate-heavy. It was a workaround, not a solution.
ES Modules (2015, Standardized)
Finally, ES2015 (ES6) introduced a native module system as part of the JavaScript language itself:
// math.js
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
// app.js
import { add, multiply } from "./math.js";
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
ES Modules are:
- Standardized: Part of the language specification, not a library
- Static: Imports and exports are determined at parse time, enabling tree-shaking
- Asynchronous: Designed for both browser and server environments
- Scope-isolated: Each module has its own scope
- Supported natively: Modern browsers and Node.js support them directly
Timeline Summary
| Era | Solution | Scope | Dependencies | Environment |
|---|---|---|---|---|
| 1995-2005 | Global scripts | Global | Implicit | Browser |
| 2005-2010 | IIFE pattern | Function | Implicit | Browser |
| 2009 | CommonJS | Module | require() | Node.js |
| 2010 | AMD / RequireJS | Module | define() | Browser |
| 2011 | UMD | Module | Detect | Both |
| 2015+ | ES Modules | Module | import/export | Both |
What Is a Module?
A module is a JavaScript file that has three defining characteristics:
- Separate file: Each module is its own file with its own isolated scope
- Own scope: Variables, functions, and classes declared in a module are not visible outside unless explicitly exported
- Explicit interface: Modules declare exactly what they share through
export, and consumers declare exactly what they need throughimport
A Module vs. a Regular Script
// ---- Regular script behavior (non-module) ----
// file: script-a.js
var sharedVar = "I'm global!";
function helperFunction() { return "I'm global too!"; }
// file: script-b.js
console.log(sharedVar); // "I'm global!" (accessible from another script)
console.log(helperFunction()); // "I'm global too!" (also accessible)
// ---- Module behavior ----
// file: module-a.js
const privateVar = "I'm private to this module";
function privateHelper() { return "I'm also private"; }
export function publicFunction() {
return privateHelper(); // Can use private helpers internally
}
// file: module-b.js
import { publicFunction } from "./module-a.js";
console.log(publicFunction()); // Works (explicitly imported)
// console.log(privateVar); // ReferenceError (not exported, not visible)
// console.log(privateHelper); // ReferenceError (not exported, not visible)
The Mental Model
Think of a module as a sealed box with labeled ports:
┌──────────────────────────────────┐
│ module-a.js │
│ │
│ const privateVar = "secret"; │
│ function privateHelper() {...} │
│ │
│ ┌─────────────────────────┐ │
│ │ export function │ ◄──── Port: publicFunction
│ │ publicFunction() {...}│ │
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ export const VERSION │ ◄──── Port: VERSION
│ │ = "1.0" │ │
│ └─────────────────────────┘ │
│ │
└──────────────────────────────────┘
Only the labeled ports (exports) are accessible from outside.
Everything else is completely hidden.
<script type="module">: Enabling Modules in the Browser
To use ES Modules in the browser, you add type="module" to your <script> tag:
<!-- Regular script: NOT a module -->
<script src="app.js"></script>
<!-- Module script: IS a module -->
<script type="module" src="app.js"></script>
<!-- Inline module -->
<script type="module">
import { greet } from "./greet.js";
greet("World");
</script>
The type="module" attribute tells the browser to treat this file as an ES module, which enables import/export syntax and activates all module-specific behaviors.
Module Scripts vs. Regular Scripts: Key Differences
<!DOCTYPE html>
<html>
<head>
<title>Modules Demo</title>
</head>
<body>
<!-- Regular script -->
<script>
var globalVar = "I pollute the global scope";
console.log(this); // Window object
</script>
<!-- Module script -->
<script type="module">
var moduleVar = "I stay inside this module";
console.log(this); // undefined (not Window!)
console.log(typeof globalVar); // "string" (can still READ globals)
// But moduleVar is NOT accessible from outside this module
</script>
<!-- Another module cannot see the first module's variables -->
<script type="module">
console.log(typeof moduleVar); // "undefined" (not visible!)
</script>
</body>
</html>
CORS Is Required
Module scripts are fetched with CORS (Cross-Origin Resource Sharing). This means you cannot load modules from the local file system with file:// protocol. You need a server:
<!-- This will FAIL if opened as a local file (file://) -->
<script type="module" src="app.js"></script>
<!-- You need a local server. For example: -->
<!-- npx serve . -->
<!-- python -m http.server -->
<!-- or use VS Code's Live Server extension -->
If you see this error:
"Access to script at 'file:///...' from origin 'null' has been blocked by CORS policy"
You need to serve your files through a web server, not open the HTML file directly.
Fallback with nomodule
For older browsers that do not support modules, you can provide a fallback script:
<!-- Modern browsers load this -->
<script type="module" src="app.modern.js"></script>
<!-- Older browsers load this instead (they ignore type="module") -->
<script nomodule src="app.legacy.js"></script>
Modern browsers that understand type="module" will ignore scripts with the nomodule attribute. Older browsers that do not understand type="module" will skip module scripts but load nomodule scripts. This provides graceful degradation.
Core Module Features: Strict Mode, Own Scope, Single Evaluation
Modules have several built-in behaviors that differ from regular scripts. Understanding these is essential for working with modules effectively.
Modules Always Run in Strict Mode
All module code executes in strict mode automatically. You do not need to write "use strict":
// module.js: strict mode is automatic
// 1. Cannot use undeclared variables
// x = 10; // ReferenceError: x is not defined
// 2. Cannot delete variables
// let y = 1; delete y; // SyntaxError
// 3. "this" at the top level is undefined (not window)
console.log(this); // undefined
// 4. Duplicate parameter names are not allowed
// function bad(a, a) {} // SyntaxError
// 5. Octal literals are not allowed
// const n = 010; // SyntaxError
// 6. Cannot assign to read-only properties
// undefined = 42; // TypeError
This is not optional. There is no way to run module code in non-strict mode. This is a deliberate design choice that eliminates an entire category of subtle bugs.
Each Module Has Its Own Scope
Variables declared in a module are scoped to that module. They do not leak into the global scope and are not visible to other modules unless explicitly exported:
// counter.js
let count = 0; // Private to this module
export function increment() {
count++;
return count;
}
export function getCount() {
return count;
}
// logger.js
let count = 0; // Different "count": completely independent
export function log(message) {
count++;
console.log(`[${count}] ${message}`);
}
// app.js
import { increment, getCount } from "./counter.js";
import { log } from "./logger.js";
increment();
increment();
log("Hello");
log("World");
console.log(getCount()); // 2 (counter's count)
// logger's count is 2 too, but it's private to logger
Even two inline modules in the same HTML page have separate scopes:
<script type="module">
const secret = "Module 1's secret";
</script>
<script type="module">
console.log(typeof secret); // "undefined" (can't see Module 1's variables)
</script>
Modules Are Evaluated Only Once
No matter how many times a module is imported, its code runs only once. All subsequent imports receive the same, already-evaluated module instance:
// config.js
console.log("Config module is loading..."); // Printed only ONCE
export const settings = {
apiUrl: "https://api.example.com",
timeout: 5000
};
export let requestCount = 0;
export function trackRequest() {
requestCount++;
}
// service-a.js
import { settings, trackRequest, requestCount } from "./config.js";
trackRequest();
console.log("Service A sees requestCount:", requestCount); // 1
// service-b.js
import { settings, trackRequest, requestCount } from "./config.js";
trackRequest();
console.log("Service B sees requestCount:", requestCount); // 2 (same module instance!)
// app.js
import "./service-a.js";
import "./service-b.js";
import { requestCount } from "./config.js";
console.log("App sees requestCount:", requestCount); // 2
Output:
Config module is loading... ← printed only once!
Service A sees requestCount: 1
Service B sees requestCount: 2
App sees requestCount: 2
Even though config.js is imported three times (by service-a.js, service-b.js, and app.js), it executes only once. All importers share the same requestCount variable. When service-a.js increments it, service-b.js sees the updated value.
This Is How Module Singletons Work
The single-evaluation behavior means that any module naturally acts as a singleton. You do not need the singleton pattern explicitly:
// database.js
class Database {
constructor() {
console.log("Database connection created");
this.connected = true;
}
query(sql) {
console.log(`Executing: ${sql}`);
}
}
// Single instance, created once
export const db = new Database();
// user-service.js
import { db } from "./database.js";
db.query("SELECT * FROM users"); // Uses the SAME db instance
// product-service.js
import { db } from "./database.js";
db.query("SELECT * FROM products"); // Uses the SAME db instance
Only one Database instance is ever created, no matter how many modules import it.
Imports Are Live Bindings
A subtle but important feature: when you import a value, you get a live binding (a live reference) to the exported variable, not a copy:
// counter.js
export let value = 0;
export function increment() {
value++;
}
// app.js
import { value, increment } from "./counter.js";
console.log(value); // 0
increment();
console.log(value); // 1 (we see the updated value!)
increment();
console.log(value); // 2 (still live!)
// But we cannot reassign an imported binding
// value = 100; // TypeError: Assignment to constant variable
// (imports are read-only from the consumer's perspective)
The importer sees changes made by the exporting module, but cannot modify the binding itself. This is different from CommonJS, where require creates a copy of the value at the time of import.
Module-Level this Is undefined
In a regular script, this at the top level refers to the global object (window in browsers):
<script>
console.log(this); // Window {...}
console.log(this === window); // true
</script>
In a module, this at the top level is undefined:
<script type="module">
console.log(this); // undefined
console.log(this === window); // false
</script>
Why This Matters
This prevents accidental use of this to access or pollute the global scope:
// Regular script
this.myGlobal = 42; // Creates window.myGlobal (global pollution)
// Module
this.myGlobal = 42; // TypeError: Cannot set properties of undefined
// Forces you to be explicit:
// window.myGlobal = 42; // If you REALLY want a global (you usually don't)
Accessing the Global Object from a Module
If you genuinely need the global object inside a module, use globalThis:
// module.js
console.log(this); // undefined
console.log(globalThis); // Window {...} (in browsers)
console.log(window); // Window {...} (browser-specific)
// To intentionally create a global (rare, but sometimes necessary for polyfills)
globalThis.myPolyfill = function() { /* ... */ };
Detecting Module vs. Script Context
You can use this to determine if code is running as a module or a regular script:
const isModule = typeof this === "undefined";
console.log(`Running as a ${isModule ? "module" : "script"}`);
defer by Default
Module scripts are automatically deferred, meaning they do not block HTML parsing. This is equivalent to having the defer attribute on a regular script.
How Regular Scripts Block Parsing
<!DOCTYPE html>
<html>
<body>
<p id="before">Before script</p>
<!-- Regular script: blocks HTML parsing -->
<script src="heavy-script.js"></script>
<!-- The browser stops parsing HTML here until heavy-script.js downloads and executes -->
<p id="after">After script</p>
</body>
</html>
With a regular script, the browser:
- Parses HTML until it hits the
<script>tag - Stops parsing HTML
- Downloads and executes the script
- Resumes parsing HTML
Module Scripts Are Deferred Automatically
<!DOCTYPE html>
<html>
<body>
<p id="before">Before script</p>
<!-- Module script: does NOT block HTML parsing -->
<script type="module" src="app.js"></script>
<!-- The browser continues parsing HTML immediately -->
<p id="after">After script</p>
</body>
</html>
With a module script, the browser:
- Parses HTML, encounters the module script
- Starts downloading the script in parallel with HTML parsing
- Continues parsing HTML without waiting
- After HTML parsing is complete, executes the module script
- Then fires
DOMContentLoaded
This means modules always have access to the complete DOM:
// app.js (loaded as type="module")
// The DOM is fully parsed by the time this runs
const button = document.getElementById("my-button"); // Always works
button.addEventListener("click", () => {
console.log("Clicked!");
});
// No need for DOMContentLoaded wrapper!
Comparison: Regular Script vs. defer vs. Module
<!-- 1. Regular script: blocks parsing, executes immediately -->
<script src="regular.js"></script>
<!-- 2. Deferred script: downloads in parallel, executes after parsing, maintains order -->
<script defer src="deferred.js"></script>
<!-- 3. Async script: downloads in parallel, executes ASAP (may be out of order) -->
<script async src="async.js"></script>
<!-- 4. Module: like defer (downloads in parallel, executes after parsing, maintains order) -->
<script type="module" src="module.js"></script>
<!-- 5. Async module: like async (downloads in parallel, executes ASAP) -->
<script type="module" async src="async-module.js"></script>
| Attribute | Downloads | Execution | Order | DOM Ready |
|---|---|---|---|---|
<script> | Blocks parsing | Immediately | In order | Maybe not |
<script defer> | Parallel | After parsing | In order | Yes |
<script async> | Parallel | When ready | No guarantee | Maybe not |
<script type="module"> | Parallel | After parsing | In order | Yes |
<script type="module" async> | Parallel | When ready | No guarantee | Maybe not |
Inline Modules Are Also Deferred
Even inline module scripts (without src) are deferred, unlike inline regular scripts:
<script type="module">
// This runs AFTER the HTML is fully parsed
console.log(document.body.children.length); // All children are available
</script>
<div>Content 1</div>
<div>Content 2</div>
<div>Content 3</div>
<script>
// Regular inline scripts run IMMEDIATELY
// At this point, only Content 1 and 2 exist in the DOM
</script>
<div>Content 4</div>
Relative Execution Order
When you have multiple module scripts, they execute in the order they appear in the HTML, just like defer:
<script type="module">
console.log("Module 1"); // Runs first
</script>
<script type="module">
console.log("Module 2"); // Runs second
</script>
<script type="module">
console.log("Module 3"); // Runs third
</script>
<!-- Output (after HTML is fully parsed):
Module 1
Module 2
Module 3
-->
Because modules are deferred by default, you can place <script type="module"> tags in the <head> without worrying about blocking page rendering. The browser downloads them in parallel with HTML parsing and executes them after the DOM is ready.
<head>
<!-- Safe in <head> does not block rendering -->
<script type="module" src="app.js"></script>
</head>
import.meta: Module Metadata
ES Modules provide an import.meta object that contains metadata about the current module. This object is only available inside module code and provides context-specific information.
import.meta.url
The most widely used property is import.meta.url, which contains the full URL of the current module:
// Module at: https://example.com/js/utils/helpers.js
console.log(import.meta.url);
// "https://example.com/js/utils/helpers.js"
In a local development server:
// Module at: http://localhost:3000/src/app.js
console.log(import.meta.url);
// "http://localhost:3000/src/app.js"
Using import.meta.url for Relative Resource Loading
import.meta.url is invaluable for loading resources relative to the current module file, regardless of where the module is imported from:
// image-loader.js, located at /js/utils/image-loader.js
export function loadImage(filename) {
// Create a URL relative to THIS module file, not the HTML page
const url = new URL(`../assets/images/${filename}`, import.meta.url);
console.log(`Loading image from: ${url}`);
const img = new Image();
img.src = url.href;
return img;
}
// app.js, located at /js/app.js
import { loadImage } from "./utils/image-loader.js";
// The image URL is resolved relative to image-loader.js, not app.js
const logo = loadImage("logo.png");
// Resolves to: /js/assets/images/logo.png (relative to image-loader.js)
Without import.meta.url, you would have to hardcode paths or use complex relative path calculations. import.meta.url always provides the absolute URL of the module file itself.
Loading JSON or Other Data Files
// data-loader.js
export async function loadJSON(filename) {
const url = new URL(filename, import.meta.url);
const response = await fetch(url);
return response.json();
}
// Usage
const config = await loadJSON("./config.json");
// Loads config.json from the same directory as data-loader.js
Loading Web Workers Relative to the Module
// worker-manager.js
export function createWorker() {
const workerUrl = new URL("./worker.js", import.meta.url);
return new Worker(workerUrl, { type: "module" });
}
import.meta in Node.js
Node.js adds its own properties to import.meta when running ES modules:
// In Node.js (with .mjs extension or "type": "module" in package.json)
console.log(import.meta.url);
// "file:///home/user/project/src/app.mjs"
// Convert to a file path
import { fileURLToPath } from "url";
import { dirname } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__filename); // /home/user/project/src/app.mjs
console.log(__dirname); // /home/user/project/src
This is how you replicate __dirname and __filename (which are not available in ES modules) in Node.js.
import.meta Is Read-Only and Module-Specific
// import.meta is unique to each module
// module-a.js
console.log(import.meta.url); // ".../module-a.js"
// module-b.js
console.log(import.meta.url); // ".../module-b.js"
// You cannot modify import.meta in a way that affects other modules
// import.meta is not shared between modules
Checking If a Module Is the Entry Point (Node.js)
In Node.js, you can check if the current module is the one being run directly (the entry point) versus being imported by another module:
// utils.js
export function helper() {
return "I'm a helper";
}
// Only run this code if the module is executed directly
// Not when imported by another module
if (import.meta.url === `file://${process.argv[1]}`) {
console.log("Running utils.js directly");
console.log(helper());
}
Putting It All Together
Here is a complete example showing all module features working together:
<!DOCTYPE html>
<html>
<head>
<title>Modules Demo</title>
<!-- Module in <head>, safe because it's deferred -->
<script type="module">
// 1. Strict mode is automatic
// x = 10; // Would throw ReferenceError
// 2. "this" is undefined
console.log("Module this:", this); // undefined
// 3. Module has its own scope
const moduleSecret = "hidden";
</script>
<!-- Fallback for old browsers -->
<script nomodule>
console.log("Your browser doesn't support modules");
</script>
</head>
<body>
<h1>Module Features</h1>
<div id="output"></div>
<script type="module" src="./app.js"></script>
<script type="module">
// 4. Deferred: DOM is ready
document.getElementById("output").textContent = "Modules loaded!";
// 5. Previous module's "moduleSecret" is not visible
console.log(typeof moduleSecret); // "undefined"
// 6. import.meta is available
console.log("Module URL:", import.meta.url);
</script>
</body>
</html>
Summary
| Feature | Regular Script | Module (type="module") |
|---|---|---|
| Scope | Global (shared) | Own scope (isolated) |
| Strict mode | Optional ("use strict") | Always on, automatic |
Top-level this | window | undefined |
import/export | Not available | Available |
| Loading behavior | Blocks parsing | Deferred (like defer) |
| Evaluated | Every time the script is loaded | Once, no matter how many imports |
| CORS | Not required for same-origin | Required |
import.meta | Not available | Available |
| Inline scripts | Execute immediately | Deferred |
| Concept | Key Takeaway |
|---|---|
| Why modules | JavaScript evolved from global scripts → IIFEs → CommonJS → AMD → ES Modules |
| Module definition | A file with its own scope, explicit exports, and explicit imports |
type="module" | Required in <script> tags to enable ES module behavior in browsers |
| Strict mode | Automatic in all modules; cannot be disabled |
| Single evaluation | Module code runs only once, even if imported by many files |
| Live bindings | Imports are live references to exported values, not copies |
| Deferred | Modules download in parallel and execute after HTML parsing completes |
import.meta.url | The full URL of the current module file; essential for relative resource loading |
ES Modules are the foundation of modern JavaScript development. They provide the code organization, encapsulation, and dependency management that the language lacked for its first 20 years. Understanding how modules behave, especially their scoping rules, single evaluation, deferred execution, and live bindings, is essential for writing clean, maintainable JavaScript applications.