Skip to content

JavaScript Modules: The Framework Behind Modern Code

Mayank
Sep 5, 202615 min read

Why Modules?

he original problem modules solved was not performance — it was namespace pollution.

Early JavaScript had no module system. Every script you loaded shared a single global scope. If two scripts both declared a variable called utils or helpers, one silently overwrote the other. Load order mattered. Dependencies were implicit. Coordinating a large codebase across multiple files meant careful manual management of what lived on window and in what order scripts were included.

The immediate fixes were patterns like IIFEs (Immediately Invoked Function Expressions) that created local scopes, and conventions like namespacing everything under a single global object. These worked until they didn’t.

The performance and organizational benefits came alongside:

  • Breaking large files into smaller, focused units makes code easier to reason about and maintain
  • Separation of concerns — authentication code has no business being entangled with payment code
  • Enabling bundler optimizations like tree shaking and code splitting — if a user never touches the payment feature, there is no reason to ship them that code

Among the many module systems that emerged — AMD, UMD, SystemJS — two matter today: ESM (ECMAScript Modules) and CJS (CommonJS).


The Two Systems

ESM CJS Syntax import / export require() / module.exports Default in Browsers Node.js Loading Async (network) Sync (filesystem) Analysis Static (compile-time) Dynamic (runtime) Bindings Live Copied values

When developers say “JS modules” without qualification, they mean ESM.


ESM — How It Actually Works

Building a module graph from import/export statements across files happens before any code executes. Imports are resolved at parse time, not runtime — this is what "static" means. You cannot write:

if (condition) {  import something from './module.js'; // Syntax error}

This static nature is what enables bundlers to do compile-time analysis. It is also what makes the loading process more complex than CJS.

ESM splits its work into three distinct phases. Each phase can be executed independently, which is what allows the file-fetching step to be non-blocking.

Phase 1 — Construction

Three things happen: find the file, fetch it, parse it into a Module Record.

Module Resolution

Starting from the entry point (typically a <script type="module"> tag), the engine reads import statements to find dependencies, fetches those files, parses them, finds their imports, and continues down the graph. It cannot know a file's dependencies until it has been parsed, and it cannot parse until it has been fetched. So it walks the tree layer by layer.

Module specifiers — the strings inside import statements — are resolved differently depending on the platform:

import x from './utils.js';        // Relative URL — valid everywhereimport x from 'https://cdn.../x';  // Absolute URL — valid everywhereimport x from 'react';             // Bare specifier — NOT valid in browsers natively

Bare imports like import React from 'react' are a Node.js/bundler convention. Browsers only resolve URLs. Bundlers intercept bare specifiers and resolve them to actual file paths. Without a bundler, in raw browser ESM, bare imports will throw.

Import Maps let you define a mapping from bare specifier to URL directly in the HTML:

<script type="importmap">{  "imports": {    "react": "https://cdn.jsdelivr.net/npm/react/+esm"  }}</script>

The Module Map

The loader keeps a registry — the module map — keyed by canonical URL. Before fetching a file, it checks the map. If a URL is already there (either fetched or in-progress), it skips it. This is how a module imported from multiple places is only fetched once.

Parse Goal and Strict Mode

Parsing a file as a module versus a regular script produces different results — this is called the Parse Goal. Modules are always in strict mode for two reasons:

  • Silent failures that strict mode catches (undeclared variables, duplicate parameters, etc.) are particularly dangerous in a modular system where errors can propagate across boundaries
  • Modules use a lexical top-level scope — a var declared at the top of a module does not create a global variable, unlike a classic script

The browser needs to know the Parse Goal before it starts parsing. You declare it on the script tag:

<script type="module" src="./app.js"></script>

Any file imported from a module is also treated as a module — you don’t need type="module" on every file. On the server side, you use .mjs or set "type": "module" in package.json (more on this below).

type="module" implies defer

Scripts with type="module" are always deferred. They do not block HTML parsing, and they execute after the document has been parsed. This is different from regular <script> tags which block parsing by default. If you are migrating code and expecting module scripts to execute at the point they appear in the HTML, they won't.

Modules and CORS

Module scripts fetched cross-origin must be served with appropriate CORS headers (Access-Control-Allow-Origin). Regular scripts do not have this requirement. If you are loading a module from a different origin without the correct headers, it will fail — and the error message is not always obvious about why.

<!-- This requires CORS headers on cdn.example.com --><script type="module" src="https://cdn.example.com/utils.js"></script>

Phase 2 — Instantiation

The engine performs a depth-first post-order traversal of the module graph — it walks down to the leaves (modules with no dependencies) and works back up. For each module, it creates a Module Environment Record that manages the module’s variables, then links exports and imports to shared memory locations.

This linking is what creates live bindings. Imports are not copies of values — they are read-only views into the exporting module’s memory. When the exporting module updates a variable, all importers see the change immediately. Only the exporting module can write to the binding; importers can read but not reassign.

This is analogous to how object references work in JavaScript — you’re not getting a copy, you’re getting a window into the same memory.

Phase 3 — Evaluation

Code runs. Variables get their actual values. Like instantiation, this happens depth-first post-order — dependencies evaluate before the modules that depend on them.

The module map ensures each module is evaluated exactly once, regardless of how many places import it. This is important for modules with side effects.


CJS — The Complete Picture

CommonJS was built for Node.js, where files live on the filesystem. There is no network latency — loading a file is synchronous. Because of this, CJS does not need separate phases. It loads, instantiates, and evaluates each module in one shot, synchronously, in the order it encounters require() calls.

module.exports vs exports

This is the most common CJS trap.

When a CJS module is initialized, Node creates an object and assigns it to both module.exports and exports. They point to the same object. You can add properties to either:

exports.greet = () => 'hello';// equivalent to:module.exports.greet = () => 'hello';

But if you reassign exports, you break the link:

exports = { greet: () => 'hello' }; // WRONG — module.exports is now still {}module.exports = { greet: () => 'hello' }; // Correct

exports is just an initial shorthand reference. module.exports is what actually gets returned by require(). If they diverge, module.exports wins.

Copied Values, Not Live Bindings

When a CJS module exports a value, the importer gets a copy at the time of import. If the exporting module later changes the value, the importer does not see the update:

// counter.jslet count = 0;module.exports = {  count,  increment() { count++; }};
// main.jsconst { count, increment } = require('./counter');increment();console.log(count); // 0 — you got a copy of the value, not a live reference

This is fundamentally different from ESM live bindings and is a frequent source of confusion when reasoning about shared state.


ESM / CJS Interop in Node.js

This is asymmetric, and the asymmetry matters.

Importing CJS from ESM — works:

// In an ESM file:import cjsModule from './legacy.cjs';// The entire module.exports object becomes the default export

Named imports from CJS do not work reliably — Node performs static analysis to try to detect named exports, but it is not guaranteed. Default import is safe.

Requiring ESM from CJS — does not work:

// In a CJS file:const esm = require('./modern.mjs'); // Error: require() of ES Module not supported

CJS require() is synchronous. ESM loading is inherently asynchronous. They are incompatible at the protocol level. If you need to load an ESM module from CJS, you must use dynamic import(), which returns a promise:

// In a CJS file:async function load() {  const esm = await import('./modern.mjs');}

This asymmetry is the primary friction point when migrating Node.js codebases from CJS to ESM.


ESM in Node.js

Two ways to tell Node to treat a file as ESM:

1. File extension .mjs:

utils.mjs → treated as ESMutils.cjs → treated as CJS

2. "type": "module" in package.json:

{  "type": "module"}

All .js files in the package are now treated as ESM. Use .cjs explicitly for any files that must remain CommonJS.


What about packages included via CDN ?

If you come from React like me, you might have come across the other way to include React in your project — via CDNs. When you add it on your dependencies, bundlers resolve your apparently bare import to a URL by itself. But when using a CDN, we should not still be able to use that way of importing react, but we can, because it’s now a global variable! react is now accessible as window.React and we technically don’t even need to import it to use it, but it is still advisable for optimizations during bundling.


Circular Dependencies

In CJS

When module A requires module B, which requires module A, CJS returns the partially-constructed module.exports object at the point the cycle is encountered — not undefined for everything, but whichever properties were assigned before the require() hit the cycle.

// a.jsconst b = require('./b');console.log('b.done =', b.done);exports.done = true;
// b.jsconst a = require('./a');console.log('a.done =', a.done); // false — a hasn't finished yetexports.done = true;

What you get depends entirely on execution order up to the point of the cycle. This is subtle and error-prone.

In ESM

ESM handles cycles better because of live bindings — by the time a module finishes evaluation, all its bindings are updated and importers see the final values. But “handles better” does not mean “handles cleanly.”

If module A imports a value from module B, and module B imports from module A, and A accesses B’s export during A’s own initialization (before B has finished evaluating), you still get undefined:

// a.jsimport { b } from './b.js';export const a = 1;console.log(b); // undefined — b.js hasn't evaluated yet when this runs
// b.jsimport { a } from './a.js';export const b = 2;console.log(a); // 1 — a.js has evaluated by the time this runs

The live binding means the value will be there eventually — but “eventually” doesn’t help if you read it during initialization before it’s been written. ESM makes cycles survivable in some cases, not safe in general.

Common cycle elimination techniques:

  • Merge the two modules into one
  • Move shared code into a third module that neither imports
  • Restructure so the dependency flows in one direction

Named vs Default Exports

Both are valid. The difference matters at scale.

Named exports:

export const add = (a, b) => a + b;export const subtract = (a, b) => a - b;
import { add } from './math.js';

Default export:

export default function add(a, b) { return a + b; }
import add from './math.js';import myAdd from './math.js'; // Works — name is arbitrary

The tradeoffs:

  • Default exports are not truly named — the importing module chooses the name, which means refactoring tools and editor autocomplete can’t reliably track them across a codebase
  • Named exports are explicit contracts — the name is part of the module’s API and importing with the wrong name is a hard error
  • Tree shaking works more reliably with named exports
  • Default exports make it easy to do accidental aliasing (import utils from './math' — what is utils actually?)

The general community direction has moved toward preferring named exports except for the primary export of a module where there is a clear single thing being exported (a React component, a class, etc.).

A detailed breakdown of the edge cases: https://jakearchibald.com/2021/export-default-thing-vs-thing-as-default/


Dynamic Imports

ESM’s static nature means you cannot conditionally import at the top level. For runtime-conditional loading, ESM provides import():

if (userIsAdmin) {  const { AdminPanel } = await import('./AdminPanel.js');}

import() looks like a function call but is not — it is a syntax keyword like super(). You cannot alias it or call/apply it:

const importAlias = import; // SyntaxError

A dynamic import creates a separate dependency graph processed independently. Any module that appears in multiple graphs is shared — the module map ensures only one instance exists per URL.

Dynamic imports work in both module scripts and regular scripts.


import.meta

import.meta is an object available inside ESM files that provides context about the current module.

console.log(import.meta.url);// e.g. "file:///Users/mayank/project/utils.js" (Node)// or "https://example.com/utils.js" (Browser)

import.meta.url is one of the few ways to get the current module's path — the ESM equivalent of __filename and __dirname in CJS (which are not available in ESM):

import { fileURLToPath } from 'url';import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);const __dirname = dirname(__filename);

In Vite and similar build tools, import.meta.env is where environment variables live:

if (import.meta.env.MODE === 'development') { ... }

Top-Level Await

Before this proposal, await was only valid inside async functions. People worked around this with IIFEs:

(async () => {  const data = await fetch('/api/data').then(r => r.json());})();

Top-level await removes the wrapper:

const data = await fetch('/api/data').then(r => r.json());export default data;

How it affects the module graph:

When a module uses top-level await:

  • Its evaluation pauses at the await expression
  • The parent module’s evaluation also pauses — it cannot proceed until its dependency has finished
  • Sibling modules (other dependencies of the parent that don’t depend on the awaiting module) continue evaluating normally
  • Once the awaited promise resolves, execution resumes in the awaiting module, then the parent

This means top-level await can change the execution order of your module graph. Without it, order is synchronous and deterministic. With it, evaluation order depends on when promises resolve. The guarantee still exists — the graph will resolve correctly — but reasoning about it is harder.

Use cases:

// Dynamic dependency pathingconst config = await import(`./config.${env}.js`);
// Resource initializationconst db = await Database.connect(process.env.DB_URL);
// Dependency fallbackslet library;try {  library = await import('preferred-library');} catch {  library = await import('fallback-library');}

Import Attributes

Importing non-JS resources like JSON directly:

// Old (deprecated):import json from './data.json' assert { type: 'json' };
// Current:import json from './data.json' with { type: 'json' };

Why this exists:

A module script fetched cross-origin could lie about its MIME type. If you import a JSON file and the server responds with text/javascript containing malicious code, you have an escalation-of-privilege attack — you imported what you thought was safe JSON and executed arbitrary JavaScript.

The assert keyword was the first attempt — it would block loading if the type didn't match, but it couldn't influence the request. This was the fatal flaw: since the assertion didn't affect the HTTP request, the browser couldn't send the correct Sec-Fetch-Dest header or respect Content Security Policies tied to the resource type.

with replaces assert with import attributes — these can influence how the module is loaded, changing the shape of the HTTP request appropriately. The attribute affects the Accept header and Sec-Fetch-Dest metadata, so the server and CSP both know what resource type is being requested.


Re-exports

Re-exporting lets you import something and immediately export it, allowing you to control the public API of a module without exposing internal file structure.

// Named re-exportexport { add, subtract } from './math.js';
// Wildcard re-exportexport * from './math.js';
// Re-exporting a default as a named exportexport { default as MathUtils } from './math.js';
// Forwarding a default exportexport { default } from './math.js';

Barrel Files

A barrel file (typically index.js or index.ts) aggregates re-exports from a directory into a single import point:

// components/index.jsexport * from './Button';export * from './Input';export * from './Avatar';
// app.js — clean importimport { Button, Input } from './components';

The convenience is real. The cost is also real.

Barrel Files and Tree Shaking

Tree shaking is dead code elimination — bundlers analyze the static import/export graph and remove exports that are never consumed, keeping the final bundle lean. It works reliably with ESM because of its static nature. CJS’s dynamic require() makes static analysis difficult, which is why CJS modules are harder to tree-shake.

The problem with barrel files is that export * creates a dependency edge between the barrel and every module it re-exports. Some bundlers and configurations treat this as a potential side-effect declaration and pull in everything, even when only one export is used:

// components/index.jsexport * from './Button';  // usedexport * from './Input';   // not usedexport * from './Avatar';  // not used
// app.jsimport { Button } from './components';// A naive bundler may include Input.js and Avatar.js anyway

This is especially pronounced in Next.js where per-page code splitting is critical to first load performance.

Mitigations:

// Explicit named re-exports instead of wildcard:export { Button } from './Button';// Don't re-export Input or Avatar if they're rarely used together
// Or import directly from the source file:import { Input } from './components/Input';

Mark modules as side-effect-free in package.json if they are — this tells bundlers they are safe to eliminate:

{If you come from React like me, you might have come across the other way to include React in your project — via CDNs. When you add it on your dependencies, bundlers resolve your apparently bare import to a URL by itself. But when using a CDN, we should not still be able to use that way of importing react, but we can, because it's now a global variable! react is now accessible as window.React and we technically don't even need to import it to use it, but it is still advisable for optimizations during bundling.  "sideEffects": false}

Or per-file if only some modules are side-effect-free:

{  "sideEffects": ["./src/polyfills.js", "*.css"]}

The "exports" Field in package.json

If you are writing a package, the "exports" field controls what consumers can import. It replaces "main" for ESM-aware tools and lets you:

  • Define separate CJS and ESM entry points
  • Block access to internal files
  • Define named subpath exports
{  "exports": {    ".": {      "import": "./dist/index.mjs",      "require": "./dist/index.cjs"    },    "./utils": "./dist/utils.mjs"  }}

With this, import { something } from 'my-package/internal/file' will throw — only the paths you define are publicly accessible. Without "exports", anyone can import any file in your package by path.


Should You Use ESM in Node.js?

CJS is still the default in Node applications, but ESM is the direction everything is moving. New tooling defaults to ESM. The Node.js team has been clear that ESM is the future. The friction in migrating is real — the interop asymmetry, the __dirname/__filename absence, the .mjs ceremony — but it is a one-time cost.

For new projects: ESM. For existing CJS projects: migrate when the tooling cost is worth it, not before.


What’s Coming in Part 2

The next part covers what happens after your module graph is built — how bundlers consume it, why the browser can’t just receive 400 individual files, how the network layer interacts with caching and your module map, and what code splitting actually means at the protocol level.

References :


If this helped, or if I got something wrong — find me on LinkedIn or my site. I read everything.

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions