JavaScript NotesRohit’s interview study guide
Chapter 09

Modules

ES modules vs CommonJS, named and default exports, live bindings, dynamic import, and why modules behave like singletons.

7 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#ES modules: named and default exports

ES modules (ESM) are JavaScript's built-in module system. In the browser you opt in with <script type="module">; in Node with .mjs files or "type": "module" in package.json.

A module is a file with its own top-level scope. Variables declared in it don't leak into the global scope, and nothing gets in or out except through explicit export and import. Before modules, every <script> shared one global scope: two libraries defining utils overwrote each other, and you had to order <script> tags by hand so dependencies loaded first. Modules fix both — each file declares exactly what it needs, and the engine works out the order.

The mental model: exports are named slots, and imports are wires connected to those slots before any code runs. An import doesn't copy a value; it gives your file a read-only view of a variable that lives in the other module. A default export is not a special mechanism either — it's just an export whose name happens to be default.

Module code is also always strict mode, top-level this is undefined, and in the browser module scripts are deferred automatically (they run after the HTML is parsed) and must be served over HTTP(S) — loading them from a file:// page fails with a CORS error in most browsers.

There are two kinds of export:

Named exports — many per file, imported with { } using the exact name

JavaScript
// math.js
export const PI = 3.14159;
export function add(a, b) {
  return a + b;
}
const secret = 42; // not exported → private to the file
JavaScript
// main.js
import { PI, add } from "./math.js";
import { add as sum } from "./math.js"; // rename

Default export — at most one per file, imported without { } under any name

JavaScript
// api.js
const api = "1234";
export default api;

// Other valid forms (still one per file):
//   export default "1234";
//   export default function getApi() {}
// ❌ export default let api = "1234";
JavaScript
// main.js
import api from "./api.js";
import whatever from "./api.js"; // same value — you pick the name
What’s happening
  1. Before any code runs, the engine parses main.js, finds its import declarations, loads and parses math.js and api.js, and links every imported name to the matching export. If a name doesn't exist — say import { secret } from "./math.js" — linking fails with SyntaxError: The requested module './math.js' does not provide an export named 'secret', and not a single line of any of the modules runs.
  2. Then evaluation starts with the dependencies. math.js runs its top level once, creating PI, add and secret. secret was never exported, so it stays private to that file — no other module can reach it.
  3. import { PI, add } must use the exported names exactly. import { add as sum } wires a different local name to the same export, so sum === add is true. Importing math.js twice doesn't run it twice — the second import reuses the already-loaded module.
  4. export default api exports the value of the expression api under the name default. import api from is shorthand for import { default as api } from, which is why the importer chooses the name: api and whatever both hold "1234".
  5. export default let api = "1234" fails because export default expects an expression (or a function/class declaration), and let api = … is a statement. Node reports it as SyntaxError: Unexpected strict mode reserved word, because in that position it reads let as an identifier.
AdvancedCombined and namespace imports
JavaScript
// Both in one import
import React, { useState, useEffect } from "react";

// Namespace import: group every named export into one object
import * as utils from "./utils.js";
utils.add(1, 2);
utils.default; // the default export, if there is one
What’s happening
  1. import React, { useState, useEffect } takes the default export (named React locally) and two named exports in one statement. The default always comes first, outside the braces.
  2. import * as utils creates a module namespace object: one property per export, a null prototype, and live values (if the module changes an exported variable, utils.thatName shows the new value).
  3. The namespace is locked: utils.foo = 1 throws TypeError: Cannot assign to property 'foo' of [object Module]. You can read exports through it, not add or replace them.
  4. utils.default is the default export, exposed under its real name. If the module has no default export, it's undefined.
  5. Bundlers can still tree-shake a namespace import as long as you only use static property access like utils.add; passing utils around as a whole value forces them to keep everything.
AdvancedPitfall: default export and extension rules
AdvancedExtensions, bare specifiers and default snapshots

Without the extension, Node fails with ERR_MODULE_NOT_FOUND. The browser has a related rule: bare specifiers like "react" don't resolve on their own — you need a bundler or an import map to tell the browser which URL "react" means.

One subtle difference between the two export styles: export default api exports a snapshot of the value, not a live binding to the variable api.

JavaScript
// dflt.js
let value = "first";
export default value;              // the value at this moment
export { value as liveDefault };   // a live binding to the variable
export function change() {
  value = "second";
}
AdvancedImporting the snapshot and the live binding
JavaScript
// main.js
import snap, { liveDefault, change } from "./dflt.js";
change();
console.log(snap, liveDefault); // first second
What’s happening
  1. export default value evaluates the expression value while dflt.js runs and stores the result ("first") in a hidden binding called default. From then on, it's disconnected from the variable.
  2. export { value as liveDefault } exports the variable itself under another name — importers get a live view of it.
  3. change() reassigns value to "second" inside dflt.js.
  4. snap still reads the hidden default binding → "first". liveDefault reads the variable → "second".
  5. In practice this rarely bites, because defaults are usually functions or classes that never get reassigned. (export default function f() {} is a live binding to f.) It's a good example of how precise the "imports are live" rule is: they're live views of bindings, and a default expression creates its own binding.
AdvancedWhy teams prefer named exports

#Re-exports and dynamic import()

Added

A re-export forwards another module's exports through the current file without using them there. The main use is a "barrel": one index.js per folder, so the rest of the app imports from ./components instead of knowing the path of every file inside it.

JavaScript
// components/index.js — a "barrel" file
export { Button } from "./Button.js";
export { default as Modal } from "./Modal.js";
export * from "./icons.js";
What’s happening
  1. export { Button } from "./Button.js" forwards Button without creating a local variable — index.js itself can't use Button unless it also imports it. It's purely a pass-through wire.
  2. export { default as Modal } from "./Modal.js" takes Modal.js's default export and re-exports it as a named export. Consumers write import { Modal } from "./components/index.js".
  3. export * forwards every named export of icons.js — but not its default. With icons.js exporting IconA, IconB and a default, the barrel's exports are Button, IconA, IconB and Modal.
  4. If two export * sources export the same name, that name becomes ambiguous and is quietly left out. Importing it explicitly then fails: SyntaxError: The requested module './cbar.js' contains conflicting star exports for name 'X'.
  5. The cost: without a bundler (Node, tests, dev servers), importing one icon through the barrel loads and runs every module the barrel mentions. Bundlers can drop unused ones in production if the package is marked side-effect-free ("sideEffects": false in package.json). Big barrels are a common reason test suites are slow.
AdvancedLazy loading with import()

import() loads a module on demand and returns a promise. Bundlers split it into a separate chunk — this is how lazy loading and code splitting work.

JavaScript
button.addEventListener("click", async () => {
  const { openEditor } = await import("./editor.js"); // downloaded only when needed
  openEditor();
});

// React
const Settings = React.lazy(() => import("./Settings.jsx"));

// Works in CommonJS too — the way to load an ESM-only package from CJS.
// CommonJS has no top-level await, so wrap it in an async function:
async function loadChalk() {
  const { default: chalk } = await import("chalk");
  return chalk;
}
What’s happening
  1. When the page loads, editor.js is not fetched. Only the click handler is registered.
  2. On the first click, import("./editor.js") immediately returns a pending promise (Object.prototype.toString calls it [object Promise]), then fetches, parses and runs editor.js and its dependencies. await waits for that; the promise resolves to the module namespace object.
  3. const { openEditor } = … destructures the named export from the namespace, and openEditor() runs. On a second click, the module is already in the cache: import() resolves to the same namespace object and nothing is downloaded or re-run.
  4. React.lazy takes a function returning that promise and expects the namespace to have a default export — the component. That's why lazily loaded components are usually default-exported (or you map it: import("./x.jsx").then((m) => ({ default: m.Settings }))).
  5. The CommonJS case: import() is allowed in CJS files, but await at the top level of a CJS file is a SyntaxError: await is only valid in async functions and the top level bodies of modules — hence the wrapper. A bare const { default: chalk } = await import("chalk") works only in an ES module.
  6. Bundlers see import("./editor.js") with a literal path, split editor.js into its own chunk at build time, and replace the call with code that fetches that chunk. A completely computed path (import(userInput)) can't be analysed, so they can't pre-build a chunk for it.
AdvancedWhy static imports are restricted

Static import declarations must be at the top level and use a literal string; import() can be used anywhere, with a computed path.

That restriction is deliberate: because static imports can't be inside an if or built from variables, tools can read the full dependency graph without running the code. That's what makes tree shaking, early "export not found" errors and fast bundling possible. import() is the escape hatch for when you genuinely need runtime decisions — loading a locale file by language code, a route's code on navigation, or a heavy library only when a feature is used. Wrap it in try/catch: on the web, a failed chunk download rejects the promise.

#CommonJS

CommonJS (CJS) is Node's original module system: require to import, module.exports (or exports) to export.

Under the hood, Node wraps every CommonJS file in a function before running it: (function (exports, require, module, __filename, __dirname) { …your file… }). That's where those "globals" come from — they're parameters. It's also why top-level variables stay private to the file (they're local to the wrapper) and why top-level this is module.exports.

require(path) is an ordinary, synchronous function call: resolve the path to a file, check the cache, and if it's not cached, create a module object, run the file's wrapper, and return module.exports. Whatever the file put in module.exports by the time it finished is what the caller gets.

JavaScript
// greet.js — exporting a single value
module.exports = function () {
  console.log("Hello from CommonJS");
};

// math.js — exporting several things
exports.add = (a, b) => a + b;
exports.PI = 3.14159;
// same as: module.exports = { add, PI };

// main.js
const greet = require("./greet");
const { add, PI } = require("./math"); // extension optional
greet();
What’s happening
  1. require("./greet") resolves the path: it tries ./greet exactly, then ./greet.js, ./greet.json, ./greet.node, and finally ./greet/index.js. It finds greet.js, runs it, and returns its module.exports.
  2. greet.js replaced module.exports with a function, so greet is that function. This is the "export one thing" style.
  3. require("./math") runs math.js, which added add and PI to the existing exports object. require returns { add, PI }, and destructuring pulls both out: add(2, 3) is 5.
  4. greet() prints Hello from CommonJS. Note the order: both files ran during the require calls, at the moment execution reached them — not before main.js started, as with ESM.
  5. Requiring ./math again returns the cached object without re-running the file; require.cache now holds entries for main.js, greet.js and math.js. Because require is just a function, you can call it inside an if or a function, with a computed path — which is exactly why tools can't analyse CommonJS statically.
AdvancedPitfall: reassigning exports

#Live bindings: the really important gotcha

ES module imports are live, read-only views of the exported variable. CommonJS gives you copied values: whatever was put into module.exports, as it was at that moment.

To be precise about where the copy happens: require returns the same module.exports object every time — a reference, not a copy. The copying happens one step earlier. When a module writes module.exports = { count }, the value of count is copied into a property, and that property is not connected to the variable afterwards. Destructuring const { count } = require(…) then copies that value again into your own variable. ESM never copies: the importer's count is the exporter's variable, seen through a read-only window.

ES modules — live

JavaScript
// counter.mjs
export let count = 0;
export function increment() {
  count++;
}
JavaScript
// main.mjs
import { count, increment } from "./counter.mjs";
console.log(count); // 0
increment();
console.log(count); // 1 ✅ sees the update

count = 5; // TypeError: Assignment to constant variable

CommonJS — copied value

JavaScript
// counter.js
let count = 0;
module.exports = {
  count,
  increment() {
    count++;
  },
};
JavaScript
// main.js
const { count, increment } = require("./counter");
console.log(count); // 0
increment();
console.log(count); // 0 ❌ still the old copy
What’s happening
  1. ESM: main.mjs's count isn't a variable of its own — it's linked to counter.mjs's count. The first log reads through the link → 0.
  2. increment() runs inside counter.mjs and changes its count from 0 → 1. The second log reads through the same link and sees 1.
  3. count = 5 throws TypeError: Assignment to constant variable. — at runtime, when that line is reached, so the two logs above have already printed. Only the module that owns a variable can reassign it. (This protects the binding, not the value: if an export is an object, importers can still mutate its properties.)
  4. CommonJS: when counter.js runs, the object literal { count, … } copies the current value 0 into a property. From then on, the module's count variable and the count property are separate.
  5. Destructuring copies 0 into main.js's local count. increment() changes the module's variable 0 → 1, but neither the property nor the local copy is touched — so it logs 0. Even reading require("./counter").count afterwards gives 0.
  6. The same applies when ESM imports this CommonJS file: import { count } from "./counter.js" also gives 0 after increment(), because the value lives in a plain property, not an exported binding.
AdvancedCommonJS: exporting a getter for live state

With CommonJS, to see current state you'd export a getter (get count() { return count; }) or a function (getCount()).

JavaScript
// counter.js
let count = 0;
module.exports = {
  get count() {
    return count;
  },
  increment() {
    count++;
  },
};

// main.js
const counter = require("./counter");
const { count } = require("./counter");
counter.increment();
console.log(counter.count, count); // 1 0
What’s happening
  1. count on the exported object is now a getter: each read of counter.count runs return count, fetching the module variable's current value.
  2. counter.increment() takes the variable 0 → 1. counter.count calls the getter → 1.
  3. const { count } = require(…) called the getter once, at destructuring time, and stored 0 in a plain local variable. It never updates.
  4. So the getter only helps if you keep the object and read through it. In CommonJS, "don't destructure state, read it from the module object" is the habit that avoids stale values.
AdvancedPitfall: destructured CommonJS methods

#CommonJS vs ES modules

CommonJSES modules
Syntaxrequire() / module.exportsimport / export
LoadingSynchronous, at runtimeAsynchronous, parsed before running
AnalysisDynamic — require can be anywhere, with any stringStatic — imports known up front
Tree shakingHardYes — bundlers drop unused exports
BindingsCopied valuesLive, read-only
Top-level awaitNoYes
this at top levelmodule.exportsundefined
__dirname, __filenameAvailableUse import.meta.dirname / import.meta.filename (Node 20.11+)
Strict modeOpt-inAlways
Runs inNode (and bundlers)Browsers, Node, Deno, Bun

The "Loading" and "Analysis" rows are the root of most of the others. An ES module graph is processed in three phases: load and parse every file reachable from the entry point, link every import to its export, and only then evaluate the code — dependencies first. CommonJS has one phase: run the file, and load each dependency the moment a require call is reached. You can see the ESM ordering directly:

JavaScript
// order-a.js
console.log("a.js runs");
export const a = "A";

// order-b.js
console.log("b.js runs");
export const b = "B";

// main.js
console.log("main.js body starts");
import { a } from "./order-a.js";
import { b } from "./order-b.js";
console.log(a, b);

// Output:
// a.js runs
// b.js runs
// main.js body starts
// A B
What’s happening
  1. Even though console.log("main.js body starts") is the first line of main.js, it does not run first. Import declarations are processed before the module body — they're effectively hoisted.
  2. The engine parses main.js, finds two imports, loads and parses order-a.js and order-b.js, and links a and b. Nothing has run yet.
  3. Evaluation is depth-first, in import order: order-a.js runs (a.js runs), then order-b.js (b.js runs), then finally main.js itself.
  4. With CommonJS require calls in the same positions, the output would follow source order — main.js body starts first — because each require runs its file only when execution reaches it.
  5. This is also why ESM can support top-level await: evaluation is already a separate, schedulable phase, so a module awaiting something just pauses the modules that depend on it. CommonJS require must return synchronously, so it has nowhere to wait.

#AMD vs CommonJS (history)

Before ES2015 there was no module system in the language, so the community made two:

  • CommonJS — synchronous require, designed for servers where files are on local disk. Became Node's system.
  • AMD (Asynchronous Module Definition, e.g. RequireJS) — asynchronous loading designed for browsers, where every module is a network request.

The split came down to latency. Reading a file from local disk takes microseconds, so making require block until the file is ready is fine on a server. In a browser, a blocking require would freeze the page during every network request, so AMD declared dependencies up front and received them in a callback once they'd all arrived.

JavaScript
// AMD
define(["jquery", "./utils"], function ($, utils) {
  return { init() { /* ... */ } };
});
What’s happening
  1. define is provided by the loader (RequireJS). Its first argument lists the dependencies by name; nothing in the module runs yet.
  2. The loader fetches jquery and ./utils — in parallel, by injecting <script> tags — and recursively fetches their dependencies.
  3. Once every dependency has loaded, the loader calls the factory function, passing each dependency's export as an argument in the same order as the array: $ gets jQuery, utils gets the utils module.
  4. Whatever the factory returns — here { init } — becomes this module's export, handed to anything that lists it as a dependency.
AdvancedUMD: one file for every loader

AMD's syntax is verbose, and once apps were bundled into one file, async per-module loading added little. UMD wrapped code so it worked in both.

JavaScript
// UMD — one file that works with AMD, CommonJS, or a plain <script>
(function (root, factory) {
  if (typeof define === "function" && define.amd) {
    define([], factory);          // AMD loader present
  } else if (typeof module === "object" && module.exports) {
    module.exports = factory();   // CommonJS (Node)
  } else {
    root.myLib = factory();       // plain <script>: attach to the global
  }
})(this, function () {
  return { hello: () => "hi" };
});
What’s happening
  1. The library's real code lives in factory, which returns the library's API ({ hello }). The outer function decides how to publish it.
  2. If an AMD loader is present (define.amd is the flag RequireJS sets), the factory is registered with define.
  3. Otherwise, if module.exports exists, it's running as CommonJS, so module.exports = factory() — require("./umd.js").hello() returns "hi".
  4. Otherwise it's a plain <script> tag: root is the global object (this at the top level of a classic script), so it becomes window.myLib.
  5. This is why older npm packages ship a dist/lib.umd.js: a single build that drops into any environment. ES modules made this unnecessary.
AdvancedCorrecting my notes: ES module support

#Modules are singletons

A module's top-level code runs once, the first time it's imported. After that, every importer gets the same cached instance. (Same for CommonJS, via require.cache.) So module-level state is naturally shared — a module is effectively a singleton.

The mental model is a registry keyed by location: the first time a module's URL (ESM) or resolved file path (CommonJS) is requested, the engine runs it and stores the result. Every later request for that same key gets the stored result back without running anything. This is why you rarely need a Singleton class in JavaScript (see Singleton) — the module system already gives you one instance per file.

JavaScript
// store.js
const globalMap = new Map(); // created once, no matter how many files import this

export default {
  getInstance() {
    return globalMap;
  },
};
AdvancedWriting to the store in a.js
a.js
import store from "./store.js";
store.getInstance().set("user", "Rohit");
AdvancedReading the same store in b.js
b.js
import store from "./store.js";
store.getInstance().get("user"); // "Rohit" — same Map
What’s happening
  1. Suppose the app's entry file does import "./a.js"; import "./b.js";. Linking sees that both a.js and b.js import ./store.js, which resolves to the same URL — so the registry holds a single store.js module.
  2. Evaluation runs dependencies first: store.js runs once, creating one Map, and its default export (the object with getInstance) is stored. A console.log at the top of store.js prints exactly once.
  3. a.js runs and calls store.getInstance().set("user", "Rohit") — the Map now holds user → "Rohit".
  4. b.js runs. Its store is the same object, so getInstance() returns the same Map, and .get("user") is "Rohit".
  5. Order matters: if b.js were imported before a.js, it would read undefined. Shared module state is only as predictable as your import order.
  6. The key includes the whole URL: import("./store.js?fresh") is a different key, so it runs store.js a second time and gives you a separate Map — handy for tests, surprising anywhere else.
AdvancedWhere module singletons show up

Exporting an object with methods (instead of exporting getInstance directly) gives the module a tidy, namespaced API: store.getInstance().

Where this shows up: configuration objects, a database connection pool, an in-memory cache, an event bus, a logger — anything the whole app should share. The flip side is testing: state set by one test leaks into the next if they share a module instance. That's why test runners isolate modules per test file, and Jest offers jest.resetModules() to get fresh copies within one file.

AdvancedEdge case: duplicate package copies

Built from Rohit’s “Javascript/HTML interview” study doc. Examples target modern browsers and Node 20+.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.