One tiny project to follow through three layers
Imagine a tiny app with two files: math.js and app.js. math.js exports a helper and a mutable value; app.js imports them and uses them to render output. We’ll follow how the two files behave in 1) browser-native modules, 2) Node.js (ESM), and 3) after a bundler transformation.
The starting files
/* math.js */
export let count = 0;
export function inc() {
count += 1;
}
/* app.js */
import { count, inc } from './math.js';
console.log('initial', count);
inc();
console.log('after inc', count);
As you read, notice two behaviors that often surprise beginners: live bindings (imports reflect module state differently than a copy) and how file paths and resolution rules affect whether the same module is the same instance.
Layer 1 — Browser-native ES modules
Modern browsers support ES modules natively when you use <script type="module">. The browser treats every module file as a separate module record and applies live bindings: named imports are references to exported values where possible, not copies.
What happens with our tiny project in the browser
- Load
<script type="module" src="/app.js">. The browser fetches/app.jsand resolves./math.jsrelative to the importing module. - Both modules are evaluated once. The
countvariable inmath.jsis the single source of truth. Importing it creates a live binding; wheninc()changes it, other modules that importcountsee the new value. - Import paths must be exact URLs or relative paths in browsers — bare specifiers like
lodashwon’t work without a mapping layer.
So in the console you’ll see:
initial 0
after inc 1
Browser modules come with built-in behavior you can rely on: scoped module records, strict mode by default, and top-level await in supportive browsers.
Layer 2 — Node.js ES modules (ESM)
Node supports ES modules with its own resolution and rules. Node’s ESM is similar in spirit to browser ESM but differs in how it resolves specifiers and handles package boundaries.
Key Node differences that affect the tiny project
- File extensions and package.json: Node uses the
typefield inpackage.json("type": "module") to treat.jsfiles as ESM. Otherwise you use.mjsor explicit flags. - Bare specifiers: Node resolves bare names (like
lodash) via node_modules and package exports; browsers refuse them unless you serve them with mappings. - Live bindings: Node also implements live bindings. If you run the same
math.jsandapp.jsunder Node ESM you’ll see identical runtime behavior for thecountexample.
Run with:
// package.json
{
"type": "module"
}
// then
node app.js
Why resolution mismatches cause bugs
Common problems occur when the same logical module gets imported by two different specifier strings, creating two module instances. For example:
// importer-a.js
import { foo } from './lib/index.js';
// importer-b.js
import { foo } from './lib'; // relies on Node resolution defaults
Depending on your tooling, those two imports might resolve to different files (./lib/index.js vs ./lib.js), or one may be transformed by a bundler differently. That creates surprising duplicate state.
Layer 3 — What bundlers actually change
Bundlers (webpack, Rollup, esbuild, Vite) sit between the source modules and the runtime. They transform module graphs into bundles optimized for delivery. But not everything disappears in a bundle — semantics like live bindings can be preserved, rewritten, or approximated.
Three common bundler behaviors
- Concatenation + wrapper: Some bundlers wrap each module in a function and place them in an object keyed by module id. Imports become calls that fetch values from that object. Properly implemented, live bindings can be preserved by using getters.
- Tree-shaking: Unused exports can be removed. Tree-shaking is static analysis — be careful when modules rely on side effects.
- Path aliasing and resolution: Bundlers let you use bare imports (
import x from 'lib') by mapping them to files or packages with plugins or config. That mapping is a common source of mismatches between dev and production environments.
What our tiny project looks like after bundling depends on the bundler and its configuration. A bundler that preserves live bindings will produce the same console output; one that copies values at build-time might not.
| Aspect | Browser-native ESM | Node.js ESM | Bundlers |
|---|---|---|---|
| Specifier format | Exact URLs or relative paths | Relative/absolute paths, bare specifiers resolved via node_modules | Any: bundler maps bare specifiers to files |
| Live bindings | Yes — real runtime references | Yes — same semantics as browser | Usually preserved, depends on implementation |
| Tree-shaking | No (server must do it) | No | Yes — removes unused exports |
| Source maps & debugging | Direct mapping to files | Direct mapping to files | Relies on source maps; mappings can be wrong if misconfigured |
Practical debugging checklist (short)
- Open DevTools or run Node with
--inspectto see loaded module specifiers and source files. - Search the bundle for the module’s path/id to confirm how the bundler packaged it.
- Check package.json
typeor.mjsextensions in Node to ensure ESM mode. - Verify source maps: wrong mappings cause breakpoints to land in unexpected files.
- Use exact relative paths in cross-environment code to reduce resolution mismatches.
Common surprises and tradeoffs
Here are frequently encountered tradeoffs and what they mean for your workflow.
1) Live bindings vs build-time copies
Live bindings are convenient because modules share state. Bundlers sometimes replace live bindings with cached values for performance, or they emulate them with getters. If your code assumes a one-time copy, it might work differently after bundling.
2) Single-file bundles vs module identity
Bundling everything into one file reduces requests but hides the module graph. If two imports resolve to the same source, bundlers may dedupe them; but if specifiers differ or aliasing differs between dev and prod, you can end up with duplicated code and duplicated state.
3) Path convenience vs portability
Bare imports are convenient, but they hide the real file paths. Use clear aliasing rules and test both the dev server and production build to prevent surprises.
Practical examples that clarify behavior
Live bindings example that can fail if a bundler copies values at build time:
// counter.js
export let x = 0;
export function bump() { x += 1; }
// a.js
import { x, bump } from './counter.js';
console.log('a sees', x);
bump();
// b.js
import { x } from './counter.js';
console.log('b sees', x);
In true ESM runtime, output is:
a sees 0
b sees 1
When debugging differences, check whether the bundler emits code that uses closures to mirror live bindings or whether it inlines the value.
Helpful reference links and deeper reading
Authoritative docs that map to the behaviors described above:
- MDN — JavaScript Modules guide
- MDN — import statement
- MDN — export statement
- Node.js — ECMAScript Modules
If you’re also revisiting function and closure behavior while learning modules, you may find these Vandutz Academy posts helpful: JavaScript functions explained, JavaScript closures explained, and for understanding how modules fit into the page lifecycle, How the browser does more than “open a page”. If you’re handling events that trigger module code, see JavaScript events explained.
Visible FAQ
Do imports copy values or reference them?
Named imports use live bindings: imports reference exported values so changes in the exporting module are visible to importers. Default exports and primitives follow the same live-binding semantics, but bundlers may emulate or alter that behavior during build-time.
Why does the same module sometimes have duplicate state?
Duplicate state usually comes from the module being imported with different specifier strings or from different bundler aliases. Make import specifiers consistent and inspect the resolved paths in both dev and production builds.
Should I always use a bundler in modern projects?
It depends. Bundlers offer optimizations (tree-shaking, transpilation, polyfills, and single-file delivery). For tiny projects or experimentation, browser-native modules can be simpler. For production apps, bundlers are almost always used for performance and compatibility, but they add a layer to debug when things go wrong.
What a module boundary does to state and dependencies
A module is more than a file split. It changes how names are exposed, how dependencies are requested, and when top-level code runs. An imported binding is not the same as copying a value into another file: ES modules provide live bindings, so the exporting module remains the owner of the binding. This is one reason a module can expose a controlled interface instead of inviting every consumer to mutate its internals.
That boundary is useful only when it is intentional. Export the smallest public surface that another part of the application needs. Keep DOM wiring in a UI module, data transformation in a pure module, and configuration at a visible boundary. This separation makes event-driven code easier to test; Vandutz’s article on JavaScript events provides the complementary browser model.
// format.js
export function formatUser(user) {
return `${user.lastName}, ${user.firstName}`;
}
// main.js
import { formatUser } from './format.js';
const label = formatUser({ firstName: 'Ada', lastName: 'Lovelace' });
console.log(label);
If the browser reports that the module cannot be found, first check the URL relative to the importing file and confirm that the server returned JavaScript rather than an HTML fallback. If the browser says a named export is missing, compare the exact exported name and case. If Node.js rejects the syntax, inspect the project’s module mode and file extension before changing the code. A bundler may hide these boundaries during development, so reproduce the smallest failure in the native environment when debugging.
When bundling improves the boundary—and when it hides it
Bundling can combine modules, transform syntax, split chunks, remove unreachable code, and provide a deployment-oriented asset graph. Those are build decisions, not changes to the conceptual module contract. Keep source modules understandable without relying on generated bundle output. When debugging a production issue, compare source maps, network requests, and the bundler’s entry points instead of assuming that a successful build proves every import path is correct.
Sources used for this explanation

Alex Carter is the editorial name behind Vandutz Academy, a programming blog for beginners. Alex reviews and tests the examples and explanations published on the site, with a focus on making Python, JavaScript, web development, and developer tools easier to understand.