When you first learn JavaScript, import
seems like wizardry.
You write:
import { readFile } from "fs";
…and suddenly readFile
exists.
But under the hood, your JS engine (V8, SpiderMonkey, JavaScriptCore, etc.) is doing a lot of work.
This is not just “copy-paste code.” It's building module graphs, parsing ASTs, caching module records, and wiring up bindings all before your code even starts running.
This is a deep dive into how JavaScript’s import system really works what the engine does, why caching matters, and how you can use this knowledge to write clean, fast, and scalable code.
🧩 What Is a Module Really?
A module is just a file, but with a twist:
- It has its own scope no leaking into global variables.
- It runs in strict mode by default.
- It exports a set of live bindings (not copies!) that other modules can import.
Think of a module as a self-contained box with inputs (imports) and outputs (exports).
🕵️ What Happens When You Import Something?
Let’s say you have this code:
import { hello } from "./greetings.js";
console.log(hello("Anik"));
Here’s what the JS engine actually does step by step:
1. Parse & Build the Module Graph
- Your file is parsed into an Abstract Syntax Tree (AST).
- All
import
andexport
statements are collected statically before running anything. - The engine builds a dependency graph of all modules.
This is why import
must be at the top level the engine needs to know all dependencies before execution starts.
2. Resolve Module Specifiers
The engine resolves "./greetings.js"
:
- If it’s relative, it resolves against the current file URL.
- If it’s bare (
react
), Node.js uses its resolution algorithm:
- Look in
node_modules
- Check
package.json
for"exports"
or"main"
- Fallback to
index.js
If it fails, you get a Cannot find module
error before any code runs.
3. Fetch & Parse the Module
- The file is fetched (from disk in Node, from network in browsers).
- Parsed into an AST.
-
Stored in memory as a Module Record a data structure containing:
- Exported bindings
- Imported bindings (references to other module records)
- Code to execute later
4. Instantiate & Link
- The engine connects imported names to exported values.
- Important: these are live bindings if a module updates an exported variable, every importer sees the new value.
// counter.js
export let count = 0;
export function increment() { count++; }
import { count, increment } from "./counter.js";
console.log(count); // 0
increment();
console.log(count); // 1 (updated!)
5. Execute
- Finally, the engine executes the module top-to-bottom.
- Side effects (console.log, DB connections, etc.) happen now but only once.
- The result is stored in the Module Map (cache).
🧠 Where Does It Cache?
JavaScript engines keep a Module Map in memory essentially a hash map from module URL → module record.
- When you import the same module again, the engine just returns the already-loaded record.
- The code is not re-executed unless you clear the cache manually (in Node via
delete require.cache[...]
).
This makes imports fast on subsequent calls and also prevents running setup logic multiple times by accident.
🗄️ The Global Module Map
Each runtime (browser tab or Node process) maintains a single global module map.
This is why:
- Reloading the page clears all modules (fresh execution).
- In Node REPL, you can clear and reload modules manually to see changes.
In Node:
delete require.cache[require.resolve("./greetings.js")];
const fresh = require("./greetings.js");
📜 import.meta
and Module Metadata
Every module has a special import.meta
object:
console.log(import.meta.url); // file:///path/to/greetings.js
You can use this to find paths relative to the current module super handy in ESM.
🏗 Project Structure & Maintainability
A good project uses modules to keep things organized:
src/
utils/
format.js
validate.js
services/
api.js
index.js
-
index.js
acts as the entry point. - Each folder groups related code.
- You can have an
index.js
insideutils/
that re-exports everything:
// utils/index.js
export * from "./format.js";
export * from "./validate.js";
Then:
import { formatResult, validateInput } from "./utils/index.js";
This gives you a clean import surface.
🎭 Static vs Dynamic Imports
Static Imports
- Resolved and loaded before execution.
- Enable tree-shaking (unused exports are dropped in production builds).
- Example:
import { sqrt } from "./math.js";
Dynamic Imports
- Loaded on demand.
- Return a promise.
- Great for lazy loading:
if (userWantsMath) {
const math = await import("./math.js");
console.log(math.sqrt(49));
}
This is how code splitting works in modern bundlers like Webpack or Vite.
🌀 Circular Imports The Spicy Corner Case
What if two modules import each other?
JavaScript can handle it, but with quirks:
// a.js
import { b } from "./b.js";
console.log("a sees b:", b);
export const a = "A";
// b.js
import { a } from "./a.js";
console.log("b sees a:", a);
export const b = "B";
Output:
b sees a: undefined
a sees b: B
Why?
Because modules are linked first, executed later.
When b.js
tries to read a
, a.js
hasn’t finished running yet so it sees the current (uninitialized) value.
Solution: Avoid circular dependencies or restructure code to break the cycle.
🏎️ Performance & Engine Optimizations
Modern JS engines (like V8 in Chrome/Node) do a lot to make imports fast:
- Parsing once and caching ASTs.
- Bytecode caching storing compiled bytecode so next load skips parsing.
- Speculative compilation compiling hot functions early.
- Tree-shaking removing dead code in builds (handled by bundlers).
You can inspect bytecode in Node with:
node --print-bytecode app.js
(Warning: Very nerdy output, but cool to see!)
🎯 Key Takeaways
- Imports are resolved, linked, and cached before execution starts.
- Modules have their own scope, run in strict mode, and only execute once.
- The engine stores modules in a Module Map reusing them on future imports.
-
import.meta
gives you module-specific metadata. - Use static imports for core dependencies, dynamic imports for lazy loading.
- Avoid circular imports or you’ll get partial initialization issues.
- Organize code into clean, modular structures for maintainability.
This isn’t just theory understanding how imports work helps you debug faster, prevent weird bugs (like partial initialization), and write code that scales as your project grows.
Top comments (0)