#CodeNewbie

#CodingTips

#Frontend

#Fullstack

#JavaScript

#LearnToCode

#Programming

#WebDevelopment

JavaScript modules deep dive: CommonJS vs ES modules vs dynamic import

As JavaScript projects grow, organising code into modules becomes essential. But JavaScript has had two competing module systems for years — CommonJS (used in Node.js) and ES Modules (the modern standard). Add dynamic imports into the mix and it can get confusing fast. This article explains all three clearly, compares them side by side, and shows you exactly when to use each one.


 

Why modules exist

Before modules, all JavaScript code shared a single global scope. Every variable you declared could clash with any library you included. Modules solve this by giving each file its own private scope — you explicitly choose what to share and what to keep private.

// Without modules — everything in global scope
var userName = "Alice";  ← could clash with another script

// With modules — private by default
const userName = "Alice";  ← only visible in this file
export { userName };      ← explicitly share if needed

 

Part 1: CommonJS (CJS)

CommonJS was the original module system for Node.js. It uses require() to import and module.exports to export. It is synchronous — the entire file is loaded before execution continues.

Exporting with CommonJS

// math.js
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
const PI = 3.14159;

// Export a single value
module.exports = add;

// Export multiple values as an object
module.exports = { add, subtract, PI };

Importing with CommonJS

// app.js
const math = require("./math");
console.log(math.add(2, 3));  → 5

// Destructured import
const { add, subtract, PI } = require("./math");
console.log(add(2, 3));  → 5

// Node built-in modules
const fs   = require("fs");
const path = require("path");
const http = require("http");

 

Part 2: ES Modules (ESM)

ES Modules are the official JavaScript standard, introduced in ES6. They use import and export keywords. Unlike CommonJS, ES modules are asynchronous and statically analysable — meaning bundlers can tree-shake unused code.

Named exports

// math.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export const PI = 3.14159;

// Or export at the bottom
function multiply(a, b) { return a * b; }
const E = 2.718;
export { multiply, E };

Default export

// Each file can have ONE default export
export default function greet(name) {
  return `Hello, ${name}!`;
}

// Or an expression
const config = { apiUrl: "https://api.example.com", timeout: 5000 };
export default config;

Importing named and default exports

// Named imports — must match export name exactly
import { add, subtract, PI } from "./math.js";

// Rename during import
import { add as sum, PI as pi } from "./math.js";

// Import everything as a namespace object
import * as Math from "./math.js";
Math.add(2, 3);  → 5
Math.PI;        → 3.14159

// Default import — choose any name you like
import greet from "./greet.js";
import myGreet from "./greet.js";  ← also valid — any name works

// Both default and named in one line
import config, { add, PI } from "./math.js";
Named export vs default export: use named exports when a file exports multiple things. Use a default export when a file has one primary thing to export — like a React component or a class. Many style guides recommend avoiding default exports because they make refactoring harder.

Re-exporting

// index.js — barrel file that re-exports from multiple modules
export { add, subtract } from "./math.js";
export { formatDate } from "./date.js";
export { fetchUser } from "./api.js";

// Now consumers import from one place
import { add, formatDate, fetchUser } from "./index.js";

 

Part 3: Dynamic import()

Static imports (import ... from ...) must be at the top of a file and are always loaded. Dynamic import() lets you load a module on demand — only when needed. It returns a Promise.

Basic dynamic import

// Static import — always loaded at startup
import { heavyLibrary } from "./heavyLibrary.js";

// Dynamic import — loaded only when needed
button.addEventListener("click", async () => {
  const { heavyLibrary } = await import("./heavyLibrary.js");
  heavyLibrary.doSomething();
});

Conditional loading

async function loadModule(type) {
  if (type === "chart") {
    const { renderChart } = await import("./chart.js");
    renderChart();
  } else if (type === "table") {
    const { renderTable } = await import("./table.js");
    renderTable();
  }
}

// Dynamic path — not possible with static import
const lang = "vi";
const translations = await import(`./locales/${lang}.js`);

Route-based code splitting (React / Vue pattern)

// React lazy loading — only load page when user navigates there
import React, { lazy, Suspense } from "react";

const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));

// Each page is only downloaded when the user visits it
// Dramatically reduces initial bundle size

 

CommonJS vs ES Modules — side by side

Feature            CommonJS (CJS)           ES Modules (ESM)
────────────────── ─────────────────────── ────────────────────
Syntax            require() / module.exports import / export
Loading           Synchronous              Asynchronous
Where used         Node.js (mainly)         Browsers + Node.js
Tree shaking       No                       Yes
Dynamic paths      Yes                      Only with import()
Top-level await    No                       Yes
File extension     .js                      .js or .mjs
Modern recommendation: use ES Modules for all new projects — browser code, Node.js 14+, and any project using a bundler like Vite or Webpack. CommonJS is still widely used in older Node.js projects and npm packages, so you will encounter both.

Key takeaways

Modules give each file its own private scope — you explicitly export what you want to share and import what you need. CommonJS uses require() and module.exports — synchronous, Node.js default, still widely used. ES Modules use import and export — the modern standard for both browsers and Node.js, supports tree shaking. Named exports are imported by exact name and support renaming. Default exports can be imported with any name. Dynamic import() loads modules on demand and returns a Promise — use it for code splitting, conditional loading, and performance optimisation. Barrel files (index.js that re-exports everything) keep imports clean in large projects.