Skip to content

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?

illustration of CJS as an old rail train and ESM as a car on a modern highway, joined by bundlers and transpilers

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.

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.

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 .cjs are treated as CommonJS
  • files with .mjs are treated as ESM
  • setting "type": "module" in package.json makes .js files 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.

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.

This post is a transcription of a video I made a while ago: