CJS vs. ESM
You have seen it: a JavaScript project with a mix of require() calls and import statements.
const module1 = require("something1");import module2 from "something2";It is confusing. Why does JavaScript, a language famous for flexibility, have two competing and (mostly) incompatible ways to handle modules?

The answer is deep in the history of JavaScript, from a simple browser scripting language to a powerhouse running servers and beyond. The existence of CommonJS (CJS) and ES Modules (ESM), and the tools that followed, explains why building modern JS is rarely just running a single file.
Here is why there are multiple ways of building and structuring JavaScript.
part 1: JavaScript’s roots and the rise of CommonJS
Section titled “part 1: JavaScript’s roots and the rise of CommonJS”In the early 2000s, JavaScript lived mostly in the browser, and it had no native module system. Sharing code meant dropping a pile of <script> tags onto an HTML page.
Node.js changed that. Running JavaScript on the server needed a way to organize code into reusable pieces, so CommonJS (CJS) arrived with require() for imports and module.exports for exports.
The CJS drawback: synchronicity.
CJS worked well for Node.js, but it is synchronous. When you call require(), the module loads and runs immediately. That design works on a server where files sit on a local disk.
In the browser, though, resources load asynchronously over a network. Synchronous loading does not fit there.
part 2: the standardization of ES modules (ESM)
Section titled “part 2: the standardization of ES modules (ESM)”As JavaScript evolved, the language gained its own native, standardized module system: ES Modules (ESM).
ESM uses the familiar import and export syntax. It is asynchronous and native to the language. That asynchronous nature enables modern optimizations like tree-shaking, which removes unused code from the final output and keeps file size down.
The catch: browsers only began to understand native import syntax after 2017.
part 3: the workarounds: bundling and transpiling
Section titled “part 3: the workarounds: bundling and transpiling”Developers wanted modern syntax (ESM, TypeScript, new language features) long before environments supported it, so a layer of build tooling became necessary.
That tooling relies on two core ideas: transpiling and bundling.
1. transpiling
Section titled “1. transpiling”Transpiling converts modern language syntax (TypeScript, ES6+) into a version of JavaScript that older or specific runtimes can run. Tools like Babel let developers write modern JavaScript, including import statements, and convert it to CJS or another compatible format while native support was missing.
On the server, transpiling turns TypeScript (.ts) into JavaScript (.js) compatible with the Node.js version in use.
2. bundling
Section titled “2. bundling”Bundling takes multiple files, transpiles them if needed, and combines them into one file or a few smaller ones. This is often required in environments that restrict file access, such as the browser (to reduce I/O loading time) or serverless and edge functions (like AWS Lambda).
Browserify historically let developers write CJS code with require() and package it into a single file the browser could execute. Today, esbuild, Webpack, Rollup, and Bun handle the same job with an eye on shipping minimal code.
part 4: modern runtimes and the future of JS
Section titled “part 4: modern runtimes and the future of JS”The need for separate tools is fading as new runtimes integrate build tooling and module handling.
Node.js still supports both systems, but you have to define how modules are treated:
- files with
.cjsare treated as CommonJS - files with
.mjsare treated as ESM - setting
"type": "module"inpackage.jsonmakes.jsfiles ESM by default
New runtimes like Bun and Deno treat ESM as the default modern standard.
Bun, built on the JavaScriptCore engine, focuses on speed with its own fast transpiler and bundler, often replacing several external tools. It runs TypeScript and JSX by transpiling them on the fly.
Bun also supports bytecode caching. Bytecode caching is a build-time optimization where the engine pre-compiles JavaScript into bytecode, a lower-level representation that runs faster than source. This moves the expensive parsing and compiling from runtime (every app start) to the build step, which speeds up startup, especially for CLIs and large applications.
conclusion
Section titled “conclusion”The many ways of building JS are a historical artifact of the language and its environments.
CJS (require) solved server-side needs but is synchronous and tied to legacy Node.js code. ESM (import) is the modern, asynchronous standard.
Bundling and transpiling exist to push new language standards into older or resource-constrained environments like browsers and serverless. The tooling is still necessary for most production deployments, but modern runtimes are folding those steps in, which makes the developer experience faster. What was once a “gambiarra linda” is now a well-optimized process.
see also
Section titled “see also”This post is a transcription of a video I made a while ago: