ES modules are JavaScript's official system for splitting a program across several files. A module file uses export to publish values and import to pull them in, and the browser loads the graph of files for you. The syntax you meet here is the same syntax Node uses, which means code written for the browser can move to a server or a toolchain without a rewrite.
Why split a script into separate files at all?
A single app.js works fine for a page with two features. It stops working when you add a third: search, a cart, a theme switcher, and a shared helper all living in one file means every unrelated change risks breaking something you did not touch. Modules give each file one job and a named public surface, so the editor can jump to the right place and the browser can cache each file separately.
The older <script> tag gives you no such guarantee. Every classic script shares one global scope, so two files that both declare const total collide, and the later file overwrites anything the earlier one set on window. A module gets its own scope instead. Nothing inside it leaks out unless you export it.
How do you tell the browser to load a module?
Add the type="module" attribute. That single attribute changes three things at once: the file is parsed as a module, it is deferred until the document has finished parsing, and it is fetched in strict mode with its own scope.
<!-- index.html -->
<script type="module" src="./app.js"></script>
<script type="module" src="./analytics.js"></script>
Because module scripts are deferred by default, you can place the tag in the <head> without worrying about ordering. If you prefer an inline module, put the code straight between the tags. Inline module scripts run after the document is parsed but before deferred external modules, so keep the order in mind when two of them depend on each other.
Which export form should you use, and how does the import side work?
Named exports are the default choice because they rename nothing on import. The importer asks for a specific name, and if you rename it in the source file the import breaks loudly instead of silently.
// math.js
export const PI = 3.14159;
export function area(radius) {
return PI * radius * radius;
}
export function perimeter(radius) {
return 2 * PI * radius;
}
The importer names exactly what it wants. A live import has no commas and no semicolon at the end of the statement, which is a common source of syntax errors when you copy code between files.
// app.js
import { area, perimeter } from './math.js';
import { formatMoney } from './money.js';
const r = 3;
console.log(area(r).toFixed(2));
console.log(formatMoney(perimeter(r)));
That prints 28.27 and then $18.85. The two numbers come from math.js; money.js only formats the second one, which is the point of keeping the two modules separate.
| Form | Written as | Good for |
|---|---|---|
| Named | export function area() {} | Files with several related helpers |
| Default | export default class Cart {} | A file whose main export has one obvious name |
| Renamed import | import { area as circleArea } from './math.js' | Two files export the same name |
| Namespace import | import * as math from './math.js' | Passing a whole module around as an object |
Imports are hoisted. The module graph is resolved and evaluated before any of the importing file's own statements run, so you can write an import at the bottom of a file and it still works. That is a good reason to keep imports at the top anyway: a reader should not have to know about hoisting to follow your file.
Why does a changed export show up in the importer?
ES module imports are live bindings, not snapshots. The importer holds a reference to the exporting module's variable, so when the exporter reassigns it, the importer sees the new value. This differs from CommonJS, where require hands over a copied value.
// counter.js
export let count = 0;
export function bump() {
count += 1;
}
// main.js
import { count, bump } from './counter.js';
console.log(count); // 0
bump();
console.log(count); // 1
The second console.log prints 1 even though the import statement sits at the top of the file. If you had assigned const count = 0 in the importer instead, the two would drift apart permanently.
What happens when two modules import each other?
A cycle is legal, and the module system handles it without crashing, but the evaluation order decides what each file can use during its own top-level code. Function declarations are hoisted, so a function can call into the other module even though that module has not finished evaluating yet.
// a.js
import { fromB } from './b.js';
export const label = 'A';
export function fromA() { return 'A calling ' + fromB(); }
// b.js
import { label } from './a.js';
export function fromB() { return 'B seeing ' + label; }
// cycle-main.js
import { fromA } from './a.js';
console.log(fromA()); // A calling B seeing A
Reading a const that has not been initialised yet throws a reference error, so keep top-level module code free of cross-module reads and move that work into functions you call later.
Why does my module fail to load with no useful error?
Three failures account for most broken module pages, and the browser message is often unhelpful enough that you need to know them in advance.
- Opening the file directly. A page loaded from
file://cannot fetch other files, because the browser treats each local file as an opaque origin. Serve the folder over HTTP instead:python -m http.server 8000and openhttp://localhost:8000. - A missing extension.
'./math'does not resolve. Write'./math.js'with the full file name. - A wrong path. Paths in an import resolve against the importing file, not against the page. A path that works from
index.htmlcan be wrong fromjs/app.js, where you need'./math.js'instead of'js/math.js'.
What to remember about ES modules
- Each module file has its own scope; only exported names are visible elsewhere.
- Use
type="module"on the script tag; you get deferral and strict mode for free. - Prefer named exports. They document the module's public surface and break loudly when renamed.
- Imports are live bindings, so reassigning an exported
letis visible to every importer immediately. - Serve your page over HTTP. The
file://protocol cannot load module files.
Try it yourself: Create a folder with index.html, math.js and app.js. Export a function discount(price, percent) from math.js, import it in app.js, and log the result for a 1250 price at 20 percent. Then change discount to return a rounded number and re-run without touching app.js — the import picks up the new behaviour. Open DevTools first: a module error appears in the console with the file and line that failed, while a classic script error often does not.