📦 How JavaScript Imports Actually Work, A Deep Dive for Devs Who Love to Know "Why"

Full-Stack Developer & Tech Writer specializing in Python (Django, FastAPI, Flask) and JavaScript (React, Next.js, Node.js). I build fast, scalable web apps and share practical insights on backend architecture, frontend performance, APIs, and Web3 integration. Available for freelance and remote roles.
If you’ve been writing JavaScript for a while, you’ve probably typed:
import { readFile } from "fs";
…and moved on with your day.
But have you ever stopped and thought:
“Wait. Where does this readFile even come from?”
Under the hood, your JS engine (V8, SpiderMonkey, JavaScriptCore…) is doing way more than just “copy-pasting some code.”
It’s building module graphs, parsing ASTs, linking live bindings, and caching results all before your code runs.
Let’s lift the hood and see what’s really going on.
You’ll come away writing faster, cleaner, and more predictable code.
🧩 So, What Is a Module Really?
A module isn’t just “a file.”
It’s a sealed box with:
Its own scope (no global leaks 🎉)
Strict mode enabled by default
Live bindings (not copies!) that other modules can subscribe to
Think of it like a microservice it takes imports as inputs, does its work, and exposes exports as outputs.
🔍 Step-by-Step: What Happens When You import
Let’s take this example:
import { hello } from "./greetings.js";
console.log(hello("Anik"));
Here’s what really happens under the hood.
1️⃣ Parse & Build the Module Graph
The JS engine:
Parses your file into an AST
Collects all
import/exportstatements staticallyBuilds a dependency graph for all modules
This is why imports must be top-level the engine needs to know every dependency before it starts running your code.
2️⃣ Resolve Specifiers
If you import "./greetings.js":
Relative imports → resolved against the current file URL
Bare imports (like
"react") → Node does this dance:Look in
node_modulesRead
package.json(exportsormain)Fallback to
index.js
If resolution fails, you’ll get Cannot find module before execution begins.
3️⃣ Fetch & Parse
The file is then fetched (disk in Node, network in browsers) and parsed again into an AST.
The engine stores it as a Module Record, which contains:
Its exported bindings
References to its imports
The executable code (but not run yet)
4️⃣ Instantiate & Link
Now, the engine wires everything together.
Imported variables point directly to the module’s exports as live bindings:
// 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 ✅ (auto-updated)
5️⃣ Execute
Finally, the module executes top-to-bottom.
Side effects happen here (logs, DB connects, etc.)
Execution happens only once
The result is stored in memory for later reuse
🧠 Module Caching: Your Hidden Performance Boost
Every runtime keeps a Module Map (URL → Module Record).
When you import the same file again:
The engine reuses the cached record
No re-execution happens (unless you clear cache manually)
This is why you don’t accidentally re-run initialization logic twice.
🌍 Global Module Map = Fresh per Runtime
Each browser tab or Node process has its own module map:
Refresh the page → clean slate
In Node, you can force a reload:
delete require.cache[require.resolve("./greetings.js")];
require("./greetings.js");
📜 Meet import.meta
Each module gets its own import.meta object think of it as the module’s passport:
console.log(import.meta.url);
// file:///absolute/path/to/greetings.js
Useful for resolving file-relative paths dynamically.
🏗 Organizing Your Codebase Like a Pro
Here’s a solid folder structure:
src/
utils/
format.js
validate.js
services/
api.js
index.js
You can even create a utils/index.js to re-export everything:
export * from "./format.js";
export * from "./validate.js";
Then just:
import { formatResult, validateInput } from "./utils/index.js";
✅ Cleaner imports
✅ Easier to refactor later
⚡ Static vs Dynamic Imports
Static Imports
✅ Loaded before execution
✅ Enable tree-shaking
import { sqrt } from "./math.js";
Dynamic Imports
✅ Loaded on demand
✅ Great for lazy-loading features
if (userWantsMath) {
const math = await import("./math.js");
console.log(math.sqrt(49));
}
Perfect for code-splitting in frameworks like Next.js, Vite, or Webpack.
🌀 Circular Imports: Handle With Care
Example:
// 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
Because linking happens first, execution later when b.js runs, a.js isn’t done yet.
Pro tip: Break the cycle by refactoring shared logic into a third module.
🏎 Engine Optimizations That Make This Fast
Modern JS engines (V8, SpiderMonkey) do some serious magic:
AST caching Parse once, reuse
Bytecode caching Skip parsing on reload
Speculative compilation Optimize hot functions early
Tree-shaking (via bundlers) removes unused code
If you’re curious, you can even see V8’s bytecode:
node --print-bytecode app.js
(Warning: It’s very nerdy, but very cool 😎)
🎯 Key Takeaways
Imports are resolved, linked, and cached before execution
Modules execute once per runtime
Use static imports for core code, dynamic imports for lazy-loaded features
Avoid circular imports they can cause weird partial initialization
Good folder structure makes scaling easier
Understanding this flow helps you write more predictable, faster code.
No more “why is my module running twice?” moments and no more mysterious undefined exports.



