JavaScript Modules Explained (Without the Hand-Waving)

If you write JavaScript long enough, you eventually hit this wall:

  • Some files use import
  • Some use require
  • Something works in Vite but explodes in Node
  • A package ships two builds and nobody knows why

That’s not bad luck. That’s you not having a clean mental model of modules.

Let’s fix that.

The real problem modules solve

Here’s the thing: modules aren’t about syntax.
They’re about control.

Without modules:

  • Everything leaks into global scope
  • File order matters
  • Refactoring is scary
  • Large apps rot fast

Modules give you:

  • Explicit boundaries
  • Predictable dependencies
  • Isolation
  • The ability to reason about code without loading the whole app into your brain

If you don’t care about those, stop reading and go write spaghetti.

ES Modules (ESM): the present and the future

ES Modules are not “new” anymore. They’re the default direction of the JavaScript ecosystem.

Browsers support them. Node supports them. Bundlers are built around them. Deno and Bun don’t even pretend CommonJS is first-class.

What ESM looks like

// math.js
export function add(a, b) {
  return a + b
}

export const PI = 3.14159
// app.js
import { add, PI } from './math.js'

add(2, 3)

Nothing fancy. That’s the point.

Rules you must internalize

  • import and export only work at the top level
  • Imports are static and known ahead of time
  • Every file has its own scope
  • Strict mode is automatic
  • You cannot “half-load” a module

These constraints are not limitations. They’re why ESM works.

Why ESM wins

Static imports mean tools can:

  • Analyze dependencies
  • Remove unused code (tree-shaking)
  • Split bundles intelligently
  • Optimize aggressively

This is why modern builds are fast and old ones weren’t.

CommonJS: legacy you still need to survive

CommonJS exists because Node existed before JavaScript had a real module system.

You will encounter it. You should not romanticize it.

What it looks like

// math.js
function add(a, b) {
  return a + b
}

module.exports = { add }
// app.js
const { add } = require('./math')

Why it causes problems

CommonJS is:

  • Dynamic
  • Synchronous
  • Runtime-driven

That means:

  • Tools can’t safely analyze it
  • Tree-shaking is unreliable
  • Exports can change at runtime
  • Mixing with ESM gets messy fast

You should read CommonJS comfortably.
You should avoid writing new CommonJS unless a tool forces you.

How Node decides what kind of module you’re using

This is where most “why is Node yelling at me” bugs come from.

Node uses signals:

package.json

{
  "type": "module"
}

With that:

  • .js files are ESM

Without it:

  • .js files are CommonJS
  • .mjs is ESM
  • .cjs is CommonJS

When Node says:

Cannot use import statement outside a module

It’s not being cryptic.
It’s telling you your file is being treated as CommonJS.

Named exports vs default exports (this is not bikeshedding)

Named exports

export function add() {}
export function subtract() {}
import { add } from './math.js'

This is boring. Good.

Benefits:

  • Clear public API
  • Better autocomplete
  • Safer refactors
  • Consistent imports across a codebase

Default exports

export default function add() {}
import add from './math.js'

Default exports are fine only when there’s exactly one obvious thing.

Overuse them and your codebase becomes a guessing game.

Dynamic imports: when static isn’t enough

const module = await import('./heavy-feature.js')

Use this when:

  • Loading optional features
  • Splitting large bundles
  • Implementing plugins
  • Delaying expensive code

Dynamic imports are how modern apps stay fast without shipping everything upfront.

Browsers, Node, and bundlers are not the same thing

This distinction matters more than most people admit.

Browsers

  • Only understand ESM
  • Require full file extensions
  • No magic resolution

Node

  • Supports ESM and CommonJS
  • Has its own resolution rules
  • Needs configuration

Bundlers

  • Rewrite imports
  • Resolve extensions
  • Polyfill behavior
  • Hide complexity

If your code only works because the bundler is babysitting it, you don’t really understand it yet.

Other module formats you should recognize

You’ll see these in old projects or libraries:

  • UMD
  • AMD
  • IIFE
  • SystemJS

Know what they are.
Don’t waste time writing them.

Modules are just the beginning

If you’re serious about full-stack JavaScript, modules connect to everything:

  • Runtime environments (browser, Node, edge)
  • Packaging formats (ESM, CJS, dual builds)
  • Transpilers (TypeScript, Babel, SWC)
  • SSR vs client rendering
  • Monorepos and shared packages

If you don’t know where your code runs, you don’t control it.

Final takeaway

ES Modules aren’t just nicer syntax.
They’re the foundation that made modern JavaScript tooling possible.

Learn them properly, and everything else gets easier.
Ignore them, and you’ll keep fighting mysterious errors you don’t understand.

Next step, if you’re serious:

  • TypeScript modules vs JavaScript modules
  • Package exports maps
  • Dual ESM/CJS libraries
  • SSR and module resolution gotchas

If you want, say the word and we’ll tear into those next.

Leave a Reply