Skip to content

Module Scope and the Module Graph

Two behaviors of ES modules are easy to misunderstand until you see them in action: module scope and single execution. Both are features, not bugs — once you understand them, they become tools.

Every module has its own scope. Variables declared at the top level of a module are not accessible from other modules unless explicitly exported:

storage.js
const KEY = 'budgetbuddy_expenses'; // private to this module
export function saveExpenses(expenses) {
localStorage.setItem(KEY, JSON.stringify(expenses.map(e => e.toJSON())));
}

KEY is a module-private constant. No other file can access it. If main.js tries import { KEY } from './storage.js', it gets undefinedKEY is not exported.

This is exactly what you want. KEY is an implementation detail of storage.js. No other file needs to know or care what the localStorage key is.

No matter how many files import the same module, the module’s top-level code runs exactly once. Subsequent imports reuse the already-executed module.

logger.js
console.log('logger module loaded'); // runs once
export function log(msg) {
console.log(`[BudgetBuddy] ${msg}`);
}
main.js
import { log } from './logger.js'; // 'logger module loaded' prints here
import { log as l2 } from './logger.js'; // does NOT print again — module reused

This matters for modules that initialize something on load — a database connection, a config object, a shared singleton. They initialize once and every importer gets the same instance.

When the browser parses main.js and sees import { Expense } from './expense.js', it fetches expense.js. If expense.js has its own imports, the browser fetches those too — and so on, recursively. The result is a module graph: a tree of dependencies that the browser resolves before any code runs.

For BudgetBuddy:

main.js
├── expense.js (no imports)
├── storage.js
│ └── expense.js (already cached — not re-fetched)
├── api.js (no imports)
└── ui.js (no imports)

expense.js appears twice in the graph — imported by both main.js and storage.js — but it is loaded and executed only once.

A circular import is when module A imports from module B, and module B imports from module A. This is allowed in ES modules but can cause confusing behavior if a module tries to use a value before it has been initialized.

The safe rule: avoid circular imports. If two modules need each other, one of them is carrying the wrong responsibility. Move the shared code to a third module that both can import.

ES module exports are live bindings — not copies. If a module exports a variable and later changes it, importers see the updated value:

counter.js
export let count = 0;
export function increment() { count++; }
main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 — the live binding updated

In practice, you rarely export mutable variables. But understanding that exports are live — not snapshots — explains some otherwise mysterious behavior.

  1. Create a config.js module that exports a const APP_NAME = 'BudgetBuddy' and logs 'config loaded' when the module initializes.
  2. Import APP_NAME in both main.js and ui.js. Confirm 'config loaded' only appears once in the console.
  3. Add a non-exported const INTERNAL_KEY = 'secret' to config.js. Try to import it in main.js — confirm it is undefined (or causes an error).
  4. Draw (on paper or in a comment) the module graph for BudgetBuddy with all five files and their import relationships.
  • Top-level variables in a module are private to that module unless exported — module scope prevents global collisions.
  • Modules execute only once, no matter how many files import them — useful for shared singletons and one-time initialization.
  • The browser resolves the full module graph before any code runs — each module is fetched once and cached.
  • Avoid circular imports — they signal a design problem.
  • Exports are live bindings, not copies — changes to exported variables are visible to importers.