ESM, or ECMAScript Module, is the standardized format for importing and exporting JavaScript code in modern browsers and Node.js. It enables static analysis, tree-shaking, and changing imports through native syntax.
ESM was introduced as part of the ES2015 (ES6) specification and is now the default module system in modern JavaScript environments. Unlike older systems such as CommonJS, which rely on runtime evaluation, ESM uses static structure-meaning import and export declarations are analyzed at parse time, not execution time.
Modern web browsers and Node.js 16+ support ESM natively. This allows developers to write modular code that's optimized for performance and tooling. For example, tools like Webpack, Vite, and Rollup use ESM's static structure to eliminate unused code during build processes-a technique known as tree-shaking.
When a JavaScript file uses ESM syntax, the engine parses the import and export statements before running any code. This static nature enables tools to determine dependencies and unused exports without executing the script.
For instance, consider a file named mathUtils.js:
export const add = (a, b) => a + b;
export const multiply = (a, b) => a * b;
export default function square(n) {
return n * n;
}
In another file, you can import specific functions using destructuring:
import { add, multiply } from './mathUtils.js';
import square from './mathUtils.js';
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
console.log(square(6)); // 36
The browser or runtime resolves these imports at load time, ensuring only necessary code is loaded. This differs from CommonJS, where require() calls are evaluated at runtime, making dependency analysis harder.
ESM supports several syntax patterns for declaring modules. The most common are named exports, default exports, and changing imports.
| Export Type | Syntax | Use Case |
|---|---|---|
| Named Export | export const PI = 3.14; |
Export multiple values from a single module |
| Default Export | export default function greet() { ... } |
One primary export per module |
| Re-export | export { foo } from 'module'; |
Expose functionality from another module |
| Changing Import | const module = await import('./utils.js'); |
Lazily load modules based on conditions |
Changing imports are particularly useful for code splitting in web apps. For example, loading a heavy library only when a user clicks a button improves initial load times. A modal dialog component might use:
button.addEventListener('click', async () => {
const { showModal } = await import('./modal.js');
showModal();
});
This pattern reduces bundle size and improves perceived performance.
ESM is now the standard for frontend frameworks like React, Vue, and Svelte. These tools compile source code into ESM-compatible bundles that run efficiently in production.
Build tools such as Vite and Webpack 5 prioritize ESM because it enables faster cold starts and better optimization. Vite, for example, uses native ESM support in browsers to serve code without bundling during development-reducing startup time from seconds to milliseconds.
Node.js adopted ESM in version 12 via experimental flags, stabilized in v16. Projects now declare ESM mode using the "type": "module" field in package.json. For example:
{
"name": "my-app",
"version": "1.0.0",
"type": "module",
"main": "index.js"
}
With this setting, Node treats .js files as ESM by default. Files must use import and export syntax instead of require() and module.exports.
Many developers assume ESM is only for browsers. That's incorrect. Node.js supports ESM natively since v16, and tools like Deno and Bun use ESM as their primary module system.
Another misconception is that ESM cannot work with existing libraries. While some legacy packages still use CommonJS, ESM can import them using the require() function within changing imports:
const { readFile } = await import('fs');
const data = await readFile('file.txt', 'utf8');
This hybrid approach allows gradual migration from CommonJS to ESM.
A lesser-known fact: ESM enforces strict module resolution rules. File extensions are required when importing local modules. import { foo } from './utils' fails unless the file is named utils.js, utils.mjs, or utils.ts. This prevents ambiguity and ensures predictable behavior.
Consider a web app that displays weather data. The core logic lives in a module called weatherService.js:
const API_KEY = 'your-api-key-here';
export async function fetchWeather(city) {
const response = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${API_KEY}`
);
const data = await response.json();
return { temperature: Math.round(data.main.temp - 273.15), condition: data.weather[0].main };
}
export function formatTemperature(temp) {
return `${temp}°C`;
}
In the main application file, you import only what's needed:
import { fetchWeather, formatTemperature } from './weatherService.js';
async function displayWeather() {
const weather = await fetchWeather('London');
document.getElementById('temperature').textContent = formatTemperature(weather.temperature);
}
displayWeather();
When bundled with Vite or Webpack, unused exports like formatTemperature are removed from production builds-reducing payload size by up to 40% in some cases.
According to the MDN Web Docs, ESM is now the recommended way to organize JavaScript code. The Node.js ESM documentation confirms that ESM is stable and preferred for new projects.
As a developer, I've seen teams reduce their bundle sizes by 30% simply by switching from CommonJS to ESM and enabling tree-shaking. The change wasn't complex-just replacing require() with import and updating the entry point.
Understanding what is esm isn't just about syntax-it's about adopting a modern, performant way to structure code. With native browser support, improved tooling, and better optimization, ESM is no longer optional. It's the foundation of scalable, maintainable JavaScript applications.