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.
One language, two very different homes. Nearly every twist below comes from one fact: JavaScript runs in two places with opposite constraints. In the browser, a module arrives over the network, so the loader can't freeze the page waiting for it — loading must be asynchronous. On the server (Node.js), a module is a file on local disk, so reading it is instant — loading can be synchronous. A pattern that feels natural in one home is often impossible in the other. That produces two separate "make them work together" threads, and it's worth keeping them apart as you read:
- The two environments — server vs browser — were reconciled first with bundlers that translate server-style modules into something a browser can run, and finally with ESM, one system that runs natively in both.
- The two module systems — ESM vs CommonJS — were reconciled with
require(esm)and theexportsfield, so a file of one kind can load a file of the other.
Each modern section below opens with a short signpost telling you which environment — server or browser — it's about.
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: you can't just drop this file into a <script> tag, because require, module, and exports don't exist there — and fetching a dependency over the network can't block the way a synchronous require() does.
That matters because CommonJS, together with npm, was so pleasant to write that developers badly wanted to reuse the same files and the same npm packages in the browser — not rewrite everything in a second style. "I wrote it for Node, now I want it in the browser" became the defining wish of the era, and closing that gap is the source of the next decade of pain.
~2011 — AMD / RequireJS (async, for the browser)
AMD (Asynchronous Module Definition) was the browser-native answer to that gap — but note what it was not: it wasn't CommonJS "wrapped" to be async. It was a separate, competing module format with its own define() syntax, designed from the start around asynchronous loading. Instead of a synchronous require(), you declare your dependencies up front and receive them in a callback once the loader has fetched them over the network:
define(['jquery'], function ($) {
return { init: function () { /* ... */ } };
});
So AMD code couldn't be shared verbatim with Node — you had to author it in this define() form. (RequireJS did offer a CommonJS-style define(function (require, exports, module) { … }) shim, which is probably why the two get conflated — but that's AMD accommodating CommonJS syntax, not AMD being CommonJS.) 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 wrapper that did this by checking, at runtime, which module system is present and routing to it — is there an AMD define? a CommonJS module.exports? neither (so just hang the library off the global window)?
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
define(['jquery'], factory); // AMD (RequireJS in the browser)
} else if (typeof module === 'object' && module.exports) {
module.exports = factory(require('jquery')); // CommonJS (Node)
} else {
root.MyLib = factory(root.jQuery); // plain <script> — attach to the global
}
})(this, function ($) {
return { /* ...your actual module... */ };
});
Your real code lives in factory; the wrapper around it just detects the environment and hands your module to whichever loader is there. You almost never write this by hand — you'll only see it as ugly generated headers in old dist/ files.
The bundler era begins
Two distinct problems pushed the ecosystem toward bundlers.
Problem 1 — the browser couldn't run what everyone wanted to write. Developers wanted to author in CommonJS and pull packages from npm, but the browser can't execute require() at all (as above). Browserify (2011) solved this by doing the work ahead of time: starting from an entry file, it followed every require() through the whole dependency tree and concatenated it into a single file the browser could run. This is that dream finally delivered — resolved at build time rather than in the browser.
Problem 2 — shipping many small files is slow and unoptimized. Even once bundling existed, a real app is hundreds of modules plus Cascading Style Sheets (CSS), images, and other assets. Serving each as its own file meant many network round-trips and no whole-program optimization (minification, dead-code removal). webpack (2012 onward) generalized bundling to fix this: treat everything — JavaScript, CSS, images — as a node in one dependency graph, and emit a small set of 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 8, 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/exportmust sit at the top level with string literal paths. You can't build a specifier at runtime the wayrequire(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. This is the turning point for the server/browser split: for the first time, the two environments ran the same module system as a native language feature — no bundler needed just to bridge them. But the other divide remained: CommonJS didn't go away — millions of packages shipped it — and that left the ecosystem living in two worlds. Resolving that second divide 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.
ESM and CommonJS finally interoperate cleanly
Server-side (Node). The browser never ran CommonJS, so it never had this problem — this is the two module systems thread, and it lives entirely on the server.
For years the wall was one-directional and painful:
- ESM importing CommonJS: always worked. Node wraps a CommonJS (CJS) module's
module.exportsand hands it to you as the default export. - CommonJS importing ESM: threw
ERR_REQUIRE_ESM. A.cjsfile could notrequire()an ESM-only package. Your only escape was async dynamicimport().
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)
Here's the whole story in one file. Say chalk (v5+) and nanoid are ESM-only packages, and you're stuck in a CommonJS codebase that can't be rewritten overnight:
// legacy.cjs (CommonJS)
// BEFORE require(esm): this threw the moment the file loaded on older Node
const chalk = require('chalk');
// → Error [ERR_REQUIRE_ESM]: require() of ES Module chalk not supported
With require(esm), that same line just works. require() hands you the ES module's
namespace object — the bag of everything it exports — so what you reach for depends on how
the package exposes things:
// legacy.cjs (CommonJS) — Node 22.12+ / 24+
const chalk = require('chalk'); // the default export lives on `.default`
console.log(chalk.default('hello')); // chalk's main function
const { nanoid } = require('nanoid'); // named exports destructure directly
console.log(nanoid());
The one case it still can't handle is an asynchronous module. Because require() has to
return synchronously, it cannot load an ES module that uses top-level await — an await
sitting at module scope, outside any function — anywhere in its import graph:
// config.mjs (ESM) — the top-level await makes this an async module
export const config = await fetch('/config.json').then((r) => r.json());
// legacy.cjs
const { config } = require('./config.mjs');
// → Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM
// graph that uses top-level await
When you hit that wall, fall back to dynamic import() — it's asynchronous, so it can load
anything, including async modules:
// legacy.cjs
async function main() {
const { config } = await import('./config.mjs'); // always works
// ...use config
}
require() an ES module freely, and only reach for await import() when that module (or something it imports) uses top-level await.
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.
Node runs TypeScript natively (no build step)
Server-side. Browsers still can't load .ts directly — for browser code, TypeScript is stripped/compiled by your bundler (Part 8). This is about running .ts on the server.
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
.tsfile - 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(ornode --watch app.ts) for instant local feedback - Run
tsc --noEmitin 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:
| Feature | Why it fails | Fix |
|---|---|---|
enum | Emits a runtime object | Use as const objects, or opt into --experimental-transform-types |
namespace (with runtime code) | Compiles to an IIFE | Use ES modules |
Legacy experimentalDecorators | Requires transformation | Use Stage 3 (standard) decorators, or a runner like tsx |
Path aliases (@/utils) | Node ignores tsconfig.json | Use 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
}
}
The bundlers went native (Rust) — covered fully in Part 8
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 likeenum),rewriteRelativeImportExtensions(let you write./foo.tsin source) - TS 5.9:
--module node20, cleanertsc --initoutput, 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-previewpackage (thetsgobinary) 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 setup | module | moduleResolution | Notes |
|---|---|---|---|
| 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
Entirely server-side: running a file directly with Node — or Deno/Bun — on your own machine. None of this involves the browser.
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
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:
| Runner | Startup speed | Non-erasable syntax (enum, decorators, aliases) | Config needed | Best for |
|---|---|---|---|---|
node app.ts | Scripts, CLIs, backend services with modern syntax | |||
tsx | Path aliases, decorators, enum — the escape hatch | |||
ts-node | Existing repos that already configure it | |||
| Bun / Deno | An all-in-one alternative runtime |
Part 6 — How modules actually interact
The rules that decide whether a file is ESM or CommonJS, and how the two talk.
These are Node's (server) rules. The browser has no CommonJS and no package.json "type" field — there, a file is a module purely because you loaded it with <script type="module"> (or an import). Everything in this section is the server side.
1. What determines the module type of a file:
| Extension | Always |
|---|---|
.mjs | ESM |
.cjs | CommonJS |
.ts / .js | Depends 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:
| Direction | Status | The rule |
|---|---|---|
| ESM → CommonJS | import x from 'cjs-pkg' gives you module.exports as the default. Named imports work only if the package actually declares named exports. | |
| CommonJS → ESM | require('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. 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 — Caching, instances, and singletons
Server-side (Node): the CommonJS require.cache and the ESM module registry. The browser has its own per-document version of this — the module map — covered in the companion post below.
The interop rules above rest on a hidden assumption worth making explicit: how many times does a module actually run, and how many copies of its state exist? Answering that is what turns a notorious bug — the dual-package hazard — from mysterious into obvious.
A module loads once, then it's cached. The first time you require() or import a file, Node resolves the specifier to an absolute location, runs the file's top-level code, and stores the result. Every later request for that same resolved key skips all of that and hands back the same object:
- CommonJS caches under
require.cache, keyed by the resolved filename. You can inspect it, and evendelete require.cache[require.resolve('./x.js')]to force a reload — a trick used almost only in tests. - ESM caches in an internal module registry, keyed by the resolved URL. It's sealed: there's no supported way to evict an entry, which is part of why ESM is safe to statically analyze.
| CommonJS | ESM | |
|---|---|---|
| Cache | require.cache | internal module registry |
| Key | resolved filename | resolved URL |
| Top-level code runs | once per key | once per key |
| Inspect / evict? | Yes | No (sealed) |
One instance → a de-facto singleton. Because the file runs exactly once and every importer gets the cached result, anything you declare at module scope — a variable, a Map, a database connection — is shared by every importer for the life of the process. That's the entire "module singleton" pattern; no class or ceremony required:
// counter.js — its top-level code runs once, ever
export const counter = { value: 0 };
// a.js
import { counter } from './counter.js';
counter.value++;
// b.js
import { counter } from './counter.js';
console.log(counter.value); // 1 — the same object a.js just mutated
a.js and b.js receive the same counter object, not two copies. This is how a config object, a logger, or a connection pool ends up shared across a whole app for free.
The dual-package hazard — when the singleton breaks. The guarantee is "one instance per resolved key," and that's the loophole. If the same logical package is loaded under two different keys — its ESM build (.mjs) through an import in one place, its CommonJS build (.cjs) through a require in another — the cache sees two keys and runs the package twice. Now there are two instances, and the singleton assumption quietly fails:
The symptoms are baffling until you know the cache is keyed by resolved path:
- a module-level
Maplooks empty to one caller and full to another, - a "run once" initializer runs twice,
- an
instanceofcheck fails on an object from what looks like the same package.
The fix is consistency — on the app side, load a package through only one module system; on the package side, either ship a single format or route both the import and require conditions (via the exports field, Part 6) to one shared stateful core.
For the browser's equivalent — the per-document module map keyed by URL, and how it makes each file load exactly once — see the companion post, How the Browser Loads Scripts.
Part 8 — Bundling tools
Primarily browser-side — this is the tooling that reconciles the two environments, packaging your modules into something a browser loads efficiently.
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.
So was the browser always the real problem — and is that why we keep reaching for bundlers? Largely yes, but be precise about which problem. Bundlers were born because the browser couldn't run require() and had to fetch every module over a network (Part 1). That constraint is really "delivering code over a network," and it never went away: even now that browsers run ESM natively, loading a deep module graph one hop at a time is a fetch waterfall, and you still want optimizations the runtime won't do for you — tree-shaking, minification, code splitting. So the browser stopped being unable to run modules, but shipping a large app efficiently over a network is a problem bundlers still solve better than the browser can alone — which is exactly why they're still here. On the server, where modules are local files with no network hop, bundling stays optional.
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.jsonto 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
esbuildconfig block invite.configis now anoxcone.
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 situation | Reach for |
|---|---|
| New app, any framework | Vite (you're already here) |
| Migrating a big webpack project | Rspack |
| On Next.js | Turbopack (it's already the default) |
| Publishing an npm library | Rollup / Rolldown, or tsup for the ergonomic path |
| Standalone script / CLI / custom pipeline | esbuild |
(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) papering over the gap between the server and the browser, to a single language-level standard (ESM) that both the browser and the server (Node) now run natively — the first module system to span both, though runtimes took years to get there. 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.
References
- Vite 8.0 announcement — Rolldown, 10–30x faster builds and the migration figures
- VoidZero is joining Cloudflare (June 2026)
- Node.js — Modules: CommonJS and
require(esm) - Node.js — Running TypeScript natively (type stripping)
How the browser actually loads and resolves these scripts — loading order, <script> attributes, import maps, and the in-memory module graph — is its own topic. See How the Browser Loads Scripts.
