Skip to main content

A Chronological Guide to JavaScript Modules

· 17 min read
Pere Pages
Software Engineer
A timeline of JavaScript module systems, from global scripts to ES Modules

JavaScript had no built-in module system for most of its life, so the community invented one pattern after another to fill the gap. This is a chronological tour of how we got from global <script> tags to native ES Modules — and why each step happened.

From script tags to native TypeScript — how we got here, and what the tooling looks like in 2026.

JavaScript spent its first fifteen years without any real module system. Then it got several at once, competing with each other. Only recently has the dust actually settled. This post walks the timeline: fast through the messy early years, slow and detailed through the last few, because that's where the parts you actually work with today were finalized.

By the end you'll have a clear picture of: how modules load, how the two surviving systems (ES Modules (ESM) and CommonJS) talk to each other, where TypeScript fits, how to run a .ts file locally with zero setup, and which bundler to reach for.


Part 1 — The early years (the short version)

Pre-2009: no modules at all

You loaded scripts with <script> tags and everything shared one global scope. Two libraries defining $ would clobber each other. The workarounds were conventions, not features:

// The IIFE (immediately invoked function expression) — a function you call immediately, just to get a private scope
var MyApp = (function () {
var privateThing = 42;
return { getThing: function () { return privateThing; } };
})();

That's it. That was module encapsulation for years.

2009 — CommonJS (require / module.exports)

Node.js arrived and brought a server-side module system with it. Synchronous, file-based, dead simple:

// math.js
module.exports = { add: (a, b) => a + b };

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

This works great on a server where files are on local disk (reading them is instant). It does not map to the browser, where fetching a file is a network request and blocking on it is a non-starter. That mismatch is the source of the next decade of pain.

~2011 — AMD / RequireJS (async, for the browser)

AMD (Asynchronous Module Definition) solved CommonJS's "synchronous doesn't work in a browser" problem by making loading asynchronous, with a callback:

define(['jquery'], function ($) {
return { init: function () { /* ... */ } };
});

Functional, but verbose and awkward. Nobody misses writing it.

~2014 — UMD (the "works everywhere" wrapper)

Library authors wanted one file that ran under CommonJS, AMD, and plain <script> tags. UMD (Universal Module Definition) was the boilerplate that sniffed the environment and adapted. You almost never write it by hand — you'll only see it as ugly generated headers in old dist/ files.

The bundler era begins

Two problems pushed the ecosystem toward bundlers. First, browsers still couldn't do CommonJS, but developers wanted to write it everywhere. Browserify (2011) let you run require() in browser code by bundling it into one file. Then webpack (2012 onward) generalized the idea: treat everything — JavaScript, Cascading Style Sheets (CSS), images — as a module in a dependency graph, and emit optimized bundles. For most of the 2010s, "JavaScript modules in the browser" effectively meant "whatever webpack produced."

Hold that thought — bundlers come back in Part 5, because the modern ones are a completely different animal.


Part 2 — ES Modules become the standard (2015)

In 2015, ES2015 (ECMAScript 2015, aka ES6) added modules to the language itself. This is the system you should be writing today:

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

// app.js
import { add } from './math.js';

Three properties make ESM different from everything before it, and they matter:

  • Static. import/export must sit at the top level with string literal paths. You can't build a specifier at runtime the way require(someVariable) allows. This "boring" constraint is exactly what lets tools analyze your dependency graph before running the code — which is what makes tree-shaking possible.
  • Asynchronous. The loader can fetch modules over the network, so it works natively in browsers via <script type="module">.
  • Live bindings. An imported value is a live view of the export, not a copy.

The spec landed in 2015, but runtimes took years to catch up. Browsers shipped <script type="module"> around 2017–2018. Node.js is the long story:

  • Node 8.5 (2017): experimental, behind --experimental-modules
  • Node 13.2 (2019): ESM unflagged
  • Node 12.17 (2020, long-term support (LTS)): backported and stable

So by roughly 2020, both browsers and Node could run ESM natively. But CommonJS didn't go away — millions of packages shipped it — and that left the ecosystem living in two worlds. Resolving that is the story of the last few years.


Part 3 — The modern era (2024–2026), in detail

This is the part worth slowing down for. Three things changed recently that genuinely reshape how you write and run JavaScript.

3a. ESM and CommonJS finally interoperate cleanly

For years the wall was one-directional and painful:

  • ESM importing CommonJS: always worked. Node wraps a CommonJS (CJS) module's module.exports and hands it to you as the default export.
  • CommonJS importing ESM: threw ERR_REQUIRE_ESM. A .cjs file could not require() an ESM-only package. Your only escape was async dynamic import().

That second gap is why "I installed a package and now everything is broken" was the single most common Node complaint for years — a wave of popular packages (chalk v5, node-fetch v3, nanoid, and much of Sindre Sorhus's catalog) went ESM-only, and CommonJS codebases couldn't load them.

require(esm) closed the gap. Node can now synchronously require() an ES module:

  • Experimental behind a flag in Node 20.17 / 22.0
  • Unflagged in Node 20.19 / 22.12 / 23.0
  • Marked stable and warning-free by late 2025 (across the v22 and v24 LTS lines)
// legacy.cjs — this used to throw. Now it works.
const { someFn } = require('some-esm-only-package');

The one real limitation: require() must stay synchronous, so it cannot load an ES module that uses top-level await (anywhere in its import graph). If it does, you get ERR_REQUIRE_ASYNC_MODULE, and you fall back to dynamic import().

Practical takeaway for 2026: the technical reason to stay on CommonJS for new code is gone. Start new projects with "type": "module" and write ESM. Keep require(esm) in your back pocket as the bridge for incremental migration of older codebases.

3b. Node runs TypeScript natively (no build step)

This is the big one. Since Node 24 (the current LTS in 2026), you can just do this:

node app.ts

No ts-node, no tsx, no tsconfig.json, no build folder. The timeline:

  • Node 22.6: --experimental-strip-types (behind a flag)
  • Node 22.7: --experimental-transform-types (adds enums/namespaces)
  • Node 23.6 / 22.18: unflagged, on by default
  • Node 24: default behavior for any .ts file
  • Node 25.2: feature marked fully stable

How it works — and the critical caveat. Node uses type stripping, not compilation. A library called Amaro (a thin wrapper over the Rust-based SWC (Speedy Web Compiler) parser) removes type annotations and replaces them with whitespace, so line numbers stay identical and no source maps are needed. What's left is plain JavaScript handed straight to V8 (Chrome's JavaScript engine).

// what you write
function greet(name: string): string { return `Hi ${name}`; }

// what Node executes (types replaced by spaces)
function greet(name ) { return `Hi ${name}`; }

The caveat, in bold because it bites people: Node strips types, it does not check them. node app.ts will happily run code with type errors. Type checking is still the job of tsc (the TypeScript compiler). The standard 2026 setup is:

  • Run node app.ts (or node --watch app.ts) for instant local feedback
  • Run tsc --noEmit in continuous integration (CI) and in your editor for correctness

What type stripping can't handle. Because it only erases and never generates code, a few TypeScript features break:

FeatureWhy it failsFix
enumEmits a runtime objectUse as const objects, or opt into --experimental-transform-types
namespace (with runtime code)Compiles to an IIFEUse ES modules
Legacy experimentalDecoratorsRequires transformationUse Stage 3 (standard) decorators, or a runner like tsx
Path aliases (@/utils)Node ignores tsconfig.jsonUse a runner like tsx, or relative imports / subpath imports

The tsconfig.json that keeps your source compatible with native stripping:

{
"compilerOptions": {
"module": "nodenext", // or "node20"
"moduleResolution": "nodenext",
"target": "esnext",
"noEmit": true, // you're running .ts directly, not emitting
"verbatimModuleSyntax": true, // forces explicit `import type`, matches stripping
"erasableSyntaxOnly": true, // errors on enum/namespace so you catch them early
"rewriteRelativeImportExtensions": true
}
}

3c. The bundlers went native (Rust) — covered fully in Part 5

The third shift is that the whole build toolchain got rewritten in Rust/Go for 10–30x speedups, and Vite consolidated onto a single engine. That's a big enough topic to get its own section below.


Part 4 — Where TypeScript is heading

TypeScript's direction over the last two releases is a deliberate convergence with "just run it as JavaScript." Two threads:

Thread 1 — making TS erasable so runtimes can run it directly. Recent flags exist specifically to keep your code inside the subset Node can strip:

  • TS 5.8: --erasableSyntaxOnly (error on non-erasable syntax like enum), rewriteRelativeImportExtensions (let you write ./foo.ts in source)
  • TS 5.9: --module node20, cleaner tsc --init output, import defer support

Thread 2 — the compiler rewrite (the headline).

  • TypeScript 6.0 (early 2026): a transitional release, the last on the old TypeScript-based compiler codebase (nicknamed "Strada"). Its job is mostly deprecations and defaults to prepare you for the jump. Turn on the deprecation warnings and start migrating away from --baseUrl, moduleResolution: node (the old one), and ES5 targets.
  • TypeScript 7.0 — "Project Corsa" (targeted mid-to-late 2026): a full rewrite of the compiler and language service in Go, aiming for ~10x faster builds, near-instant incremental compiles, and roughly half the memory. You can try it today via the @typescript/native-preview package (the tsgo binary) and point your CI at it to check for issues.

Why this matters for the module story: once tsc --noEmit is that fast, the "skip type checking in dev because it's slow" argument mostly evaporates. The runtime strips types for speed; the checker becomes fast enough to run constantly. They stop being in tension.

Your module / moduleResolution decision menu (2026):

Your setupmodulemoduleResolutionNotes
App bundled by Vite/Rollup"esnext" or "preserve""bundler"Let the bundler handle emit; verbatimModuleSyntax: true, noEmit: true
Node app, run/emit directly"nodenext" (or "node20")"nodenext"Matches Node's real resolution incl. exports field
Legacy project"commonjs""node"Being deprecated in TS 6 — plan to move off it

For your Vite + Vitest + React stack specifically, "module": "esnext", "moduleResolution": "bundler", "verbatimModuleSyntax": true, and "noEmit": true is the standard combination — Vite/Rolldown owns the actual transformation, and TypeScript is there purely for checking.


Part 5 — Running scripts locally

Given native support, here's the decision menu from simplest to most capable.

1. Just use Node (default choice for scripts, command-line interfaces (CLIs), backend services).

node app.ts # run it
node --watch app.ts # re-run on save (replaces nodemon)
node --test # built-in test runner, no Jest/Vitest needed for plain Node
Zero dependencies, zero config. Best for scripts, internal tooling, CLIs, and small services — anything using erasable syntax and ESM.

2. Reach for tsx when you need more than stripping.

npx tsx app.ts
npx tsx watch app.ts

tsx (esbuild-based) reads your tsconfig.json, so it handles path aliases, decorators, enum, and other non-erasable features that native stripping rejects. This is your escape hatch when node app.ts complains — e.g. a NestJS app or a codebase leaning on @/-style imports.

3. ts-node — mostly legacy now. It spins up a full type-checking compiler on every boot, which makes it the slowest option (cold starts can be ~9x slower than native stripping). You'll still meet it in existing repos, but reach for native Node or tsx in new ones.

4. Deno / Bun — if you're open to a different runtime. Both run TypeScript directly out of the box and have for a while. Bun in particular doubles as a fast package manager and bundler. Node catching up on native TS, a built-in test runner, and a permission model is, frankly, largely a response to pressure from these two.

The runners at a glance:

RunnerStartup speedNon-erasable syntax (enum, decorators, aliases)Config neededBest for
node app.tsFastestNoNoneScripts, CLIs, backend services with modern syntax
tsxFastYesReads tsconfig.jsonPath aliases, decorators, enum — the escape hatch
ts-nodeSlowest (~9x)YesReads tsconfig.jsonExisting repos that already configure it
Bun / DenoFastYesOwn runtimeAn all-in-one alternative runtime

Best Good OK Worst


Part 6 — How modules actually interact

The rules that decide whether a file is ESM or CommonJS, and how the two talk.

1. What determines the module type of a file:

ExtensionAlways
.mjsESM
.cjsCommonJS
.ts / .jsDepends on the nearest package.json "type" field

For .js and .ts: "type": "module" → ESM; "type": "commonjs" (or missing) → CommonJS. This is the single biggest source of confusion — the same .js file is ESM or CJS depending entirely on where it sits in the tree. Set "type" explicitly and don't rely on ambiguity.

2. The two directions of interop:

DirectionStatusThe rule
ESM → CommonJSSmoothimport x from 'cjs-pkg' gives you module.exports as the default. Named imports work only if the package actually declares named exports.
CommonJS → ESMWorks, with a catchrequire('esm-pkg') works as of require(esm), unless the ESM graph uses top-level await — then it throws and you must use dynamic import().

3. The exports field — how a package advertises its entry points. Modern packages map different loaders to different files:

{
"exports": {
".": {
"import": "./dist/index.mjs", // when someone uses `import`
"require": "./dist/index.cjs", // when someone uses `require`
"types": "./dist/index.d.ts"
}
}
}

4. The dual-package hazard (the subtle bug to know about). If a package ships both CJS and ESM builds, and your app somehow loads it through both code paths, you get two separate instances of that module. Symptoms: a module-level Map looks empty to one caller, a singleton initializes twice, an instanceof check fails on an object from the "same" package. The fix is consistency — pick one module system for your whole app.

5. ESM has no __dirname / __filename. Use the URL (Uniform Resource Locator)-based equivalents:

import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = fileURLToPath(new URL('.', import.meta.url));

Part 7 — Bundling tools

Why bundle at all? For apps shipped to a browser, yes — you want tree-shaking (dropping unused exports, which only works because ESM is statically analyzable), code splitting, and minification. For backend Node code, bundling is optional and often skipped now that Node runs .ts directly, though a single bundled artifact still helps serverless cold-start times.

The whole toolchain got rewritten in Rust/Go over the last two years, so raw speed is no longer the differentiator — every serious tool is fast enough. The question is now ecosystem fit.

The 2026 landscape:

  • Vite (default for new apps). With Vite 8 (stable, March 2026), the old split personality — esbuild for dev, Rollup for prod — is gone. Both now run on Rolldown, a single Rust bundler with a Rollup-compatible plugin application programming interface (API), reporting 10–30x faster production builds (real migrations report 46s→6s, GitLab 2.5min→22s). One engine for dev and prod means the "works in dev, breaks in prod" class of bugs largely disappears. This is your default, and it's what your existing Vite + Vitest setup already rides on.
    • Two caveats worth pinning: Vite 8 requires Node 20.19+/22.12+ and the package is now ESM-only; and the dev process uses noticeably more random-access memory (RAM) because Rolldown holds more of the module graph in memory.
  • Rolldown / Rollup (library authors). Rollup's precise tree-shaking and clean multi-format output (ESM/CJS/UMD) still make it the library-publishing choice; Rolldown is its faster successor as it matures.
  • esbuild (the fast primitive). Less common as a primary app bundler now — Vite wraps it more ergonomically — but still the go-to for standalone scripts, CLIs, and custom pipelines where you want a simple, blazing-fast API. It also quietly powers transforms across the ecosystem.
  • Rspack (webpack migration path). A Rust reimplementation of webpack that keeps most of webpack's config and plugins. This is the "get a legacy 800-line webpack.config.js onto a fast 2026 stack without a rewrite" option.
  • Turbopack (Next.js only). Rust bundler baked into Next.js and its default as of Next.js 16. Excellent inside Next, but not really a general-purpose tool you'd adopt elsewhere — treat it as Next.js infrastructure.
  • The transform layer — Oxc and SWC. Underneath the bundlers, the parse/transform/minify work is done by Rust toolchains: Oxc (the Oxidation Compiler, now powering Vite/Rolldown) and SWC (powering Rspack and many framework transforms). You rarely touch these directly, but when Vite strips your types or lowers JSX (JavaScript XML) in Vite 8, that's Oxc, not esbuild — so your old esbuild config block in vite.config is now an oxc one.

For publishing a library, tsup (or unbuild) wraps this nicely — generate ESM + CJS + .d.ts in one command:

npx tsup src/index.ts --format cjs,esm --dts

Bundler decision menu:

Your situationReach for
New app, any frameworkVite (you're already here)
Migrating a big webpack projectRspack
On Next.jsTurbopack (it's already the default)
Publishing an npm libraryRollup / Rolldown, or tsup for the ergonomic path
Standalone script / CLI / custom pipelineesbuild

(Governance footnote worth knowing: Cloudflare acquired VoidZero — the team behind Vite, Vitest, Rolldown, and Oxc — in mid-2026, with an open-source commitment. A large slice of the ecosystem's build tooling now sits under one roof, which is worth a mental note even if nothing's changed in practice.)


The one-paragraph summary

JavaScript went from no modules, to a decade of competing hacks (CommonJS, AMD, UMD), to a single language-level standard (ESM) that runtimes took years to fully support. In 2026 the picture is finally clean: write ESM, require(esm) bridges the gap to old CommonJS code, Node runs .ts files natively via type stripping (with tsc --noEmit still doing the actual checking), TypeScript is rewriting its compiler in Go for a ~10x speedup, and Vite 8 on Rolldown has unified the bundler pipeline. For your stack, that means: node app.ts for scripts, tsx when you need path aliases or decorators, and Vite for anything shipped to a browser.