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.
Module scope in practice
Section titled “Module scope in practice”Every module has its own scope. Variables declared at the top level of a module are not accessible from other modules unless explicitly exported:
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 undefined — KEY 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.
Modules execute once
Section titled “Modules execute once”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.
console.log('logger module loaded'); // runs once
export function log(msg) { console.log(`[BudgetBuddy] ${msg}`);}import { log } from './logger.js'; // 'logger module loaded' prints hereimport { log as l2 } from './logger.js'; // does NOT print again — module reusedThis 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.
The module graph
Section titled “The module graph”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.
Circular imports
Section titled “Circular imports”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.
Live bindings
Section titled “Live bindings”ES module exports are live bindings — not copies. If a module exports a variable and later changes it, importers see the updated value:
export let count = 0;export function increment() { count++; }import { count, increment } from './counter.js';console.log(count); // 0increment();console.log(count); // 1 — the live binding updatedIn practice, you rarely export mutable variables. But understanding that exports are live — not snapshots — explains some otherwise mysterious behavior.
Exercise
Section titled “Exercise”- Create a
config.jsmodule that exports aconst APP_NAME = 'BudgetBuddy'and logs'config loaded'when the module initializes. - Import
APP_NAMEin bothmain.jsandui.js. Confirm'config loaded'only appears once in the console. - Add a non-exported
const INTERNAL_KEY = 'secret'toconfig.js. Try to import it inmain.js— confirm it isundefined(or causes an error). - 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.