ESM, CommonJS, dual packages, V8 internals, memory management, and the Node.js event loop in depth.
// Named exports
export function add(a, b) { return a + b; }
export const PI = 3.14159;
export class Vector { /* ... */ }
// Default export — one per module
export default class App { /* ... */ }
// Re-export from another module
export { add, PI } from './math.js';
export * from './utils.js'; // re-export all named
export * as utils from './utils.js'; // re-export as namespace
export { default as BaseApp } from './base.js'; // re-export default as named
// Named import
import { add, PI } from './math.js';
// Default import
import App from './app.js';
// Both default and named
import App, { version, config } from './app.js';
// Namespace import
import * as MathUtils from './math.js';
MathUtils.add(1, 2);
// Rename on import
import { add as sum, PI as pi } from './math.js';
// import.meta is available in ESM only
// URL of current module (always available)
console.log(import.meta.url); // file:///path/to/module.mjs
// Derive __dirname equivalent (Node 21.2+ has import.meta.dirname)
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Node 21.2+ — built-in equivalents
// import.meta.filename → /path/to/module.mjs
// import.meta.dirname → /path/to/
// Resolve relative paths to absolute
const configPath = import.meta.resolve('./config.json');
// Returns: file:///path/to/config.json
// Environment (Vite, Webpack inject these)
if (import.meta.env?.DEV) { /* development-only */ }
if (import.meta.hot) { /* HMR support */ }
// Lazy loading — import() returns a Promise<Module>
const { default: Chart } = await import('./chart.js');
// Conditional platform code
const platform = process.platform === 'win32'
? await import('./windows.js')
: await import('./unix.js');
// Code splitting (works in bundlers and browsers)
async function loadHeavyFeature() {
const { HeavyComponent } = await import('./heavy-component.js');
return new HeavyComponent();
}
// Dynamic path (bundlers may warn — prefer static strings)
const locale = 'en-US';
const { messages } = await import(`./locales/${locale}.js`);
// Import attributes (ES2024)
const config = await import('./config.json', { with: { type: 'json' } });
const styles = await import('./theme.css', { with: { type: 'css' } });
// import() in CJS modules — interop with ESM
// In a .cjs file, you can use dynamic import() to load ESM:
async function loadESMModule() {
const esm = await import('./esm-only.mjs');
return esm;
}
// Synchronous — entire file executes before require() returns
const path = require('node:path');
const { readFileSync } = require('node:fs');
// module.exports — single export value
module.exports = function add(a, b) { return a + b; };
// Attach multiple exports to exports object
exports.add = (a, b) => a + b;
exports.PI = 3.14159;
// Note: never replace exports itself — use module.exports
// __dirname / __filename — built-in in CJS
console.log(__dirname); // /path/to/current/directory
console.log(__filename); // /path/to/current/file.js
// require.resolve — get full path without loading
const configPath = require.resolve('./config');
// require.cache — access loaded module cache
delete require.cache[require.resolve('./module')]; // force re-load
// Conditional require for optional dependencies
let chalk;
try {
chalk = require('chalk');
} catch {
chalk = { red: s => s, green: s => s }; // fallback
}
// From CJS: load ESM with dynamic import (async!)
// require() cannot load ES modules directly
const esmModule = await import('./esm-module.mjs');
// From ESM: load CJS with static import (works!)
import cjsModule from './commonjs-module.cjs';
// Named exports from CJS — only default is guaranteed
// Some bundlers analyze CJS for named exports (Vite, Rollup)
// util.promisify — convert CJS callbacks to Promises
import { promisify } from 'node:util';
import { readFile } from 'node:fs';
const readFileAsync = promisify(readFile);
const content = await readFileAsync('./file.txt', 'utf8');
{
"name": "my-library",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./utils": {
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
},
"./package.json": "./package.json"
},
"files": ["dist"]
}
{
"exports": {
".": {
"browser": "./dist/browser.mjs",
"worker": "./dist/worker.mjs",
"node": {
"import": "./dist/node.mjs",
"require": "./dist/node.cjs"
},
"default": "./dist/index.mjs"
}
}
}
When both ESM and CJS versions are loaded in the same process, singletons (class instances, global state) can be duplicated. Guard with state stored outside module scope:
// Use a package-level state file or Symbol registry to detect duplication
// package: ./src/state.mjs
let instance = null;
export function getInstance() {
if (!instance) instance = createInstance();
return instance;
}
// package.json — mark package as free of side effects
{
"sideEffects": false
}
// Or list files with side effects
{
"sideEffects": [
"*.css",
"./src/polyfills.js",
"./src/global-setup.js"
]
}
// Barrel file pitfall — re-exporting everything kills tree shaking
// BAD: src/index.js
export * from './moduleA'; // bundler must include ALL of moduleA
export * from './moduleB';
export * from './moduleC';
// GOOD: import directly from source
import { specificThing } from './lib/moduleA';
// GOOD: barrel with explicit named exports is better
export { ThingA, ThingB } from './moduleA'; // explicit = tree-shakeable
// Side-effect-free module pattern
// Don't do top-level work that modifies globals
// BAD:
Array.prototype.sum = function() { return this.reduce((a, b) => a + b, 0); };
// GOOD:
export function sum(arr) { return arr.reduce((a, b) => a + b, 0); }
| Feature | Vite | esbuild | Rollup | Webpack 5 |
|---|---|---|---|---|
| Default output | ESM + CJS | ESM/CJS/IIFE | ESM + CJS | CJS/ESM |
| Tree shaking | Yes (Rollup) | Yes | Yes | Yes |
| Code splitting | Yes | Yes | Yes | Yes |
| CJS named imports | Analyzed | Analyzed | Analyzed | Analyzed |
sideEffects respected |
Yes | Yes | Yes | Yes |
| Top-level await | Yes | Yes | Yes | Partial |
| Import attributes | Planned | No | Plugin | Loader |
| Speed | Fast (esbuild) | Fastest | Moderate | Slow |
// Vite — resolves modules with node resolution + browser overrides
// vite.config.js
export default {
build: {
lib: {
entry: './src/index.ts',
formats: ['es', 'cjs'],
fileName: (format) => `my-lib.${format === 'es' ? 'mjs' : 'cjs'}`,
},
rollupOptions: {
external: ['react', 'vue'], // don't bundle peer deps
},
},
};
// V8 creates a "hidden class" (shape) for each object structure
// Objects with the same properties in the same ORDER share a shape
// GOOD — consistent shape
function Point(x, y) {
this.x = x; // always in this order
this.y = y;
}
const p1 = new Point(1, 2); // shape: { x, y }
const p2 = new Point(3, 4); // same shape — fast!
// BAD — dynamic property addition changes shape
const obj = {};
obj.x = 1; // shape 1: { x }
obj.y = 2; // shape 2: { x, y } — shape transition!
// BAD — adding properties in different orders
function makePoint(x, y, swap) {
const p = {};
if (swap) { p.y = y; p.x = x; } // different order!
else { p.x = x; p.y = y; }
return p;
}
// p.x may hit a different shape → slower property access
// V8 caches property lookup results at each call site
// Monomorphic (1 shape) → fast
// Polymorphic (2-4 shapes) → slower
// Megamorphic (5+ shapes) → very slow, no caching
// GOOD — function always receives same shape
function area(rect) {
return rect.width * rect.height; // monomorphic — one shape
}
area({ width: 10, height: 20 });
area({ width: 5, height: 15 });
// BAD — function receives many different shapes
function getProperty(obj, key) {
return obj[key]; // megamorphic — every object is different
}
// Things that prevent or undo JIT optimization:
// 1. typeof checks can hint at types — use them consistently
// 2. delete operator changes object shape
delete obj.property; // BAD for perf — sets to undefined instead
// 3. arguments object prevents optimization
function sum() {
let total = 0;
for (let i = 0; i < arguments.length; i++) total += arguments[i]; // slow
return total;
}
// GOOD: use rest parameters
function sum(...nums) {
return nums.reduce((a, b) => a + b, 0);
}
// 4. Changing array element types
const arr = [1, 2, 3]; // SMI (small integer) array — fastest
arr.push(1.5); // now DOUBLE array — shape changed
arr.push('hello'); // now ELEMENTS array — slowest
// 5. try/catch in hot loops (older V8; mostly fixed in Node 12+)
// Still worth moving try/catch outside tight loops when possible
// V8 uses generational GC:
// Young generation (Scavenger) — short-lived objects, collected frequently, fast
// Old generation (Mark-Sweep-Compact) — survived 2 young GCs, collected less often
// Objects become unreachable when no references point to them
function createLeak() {
const largeData = new Array(1_000_000).fill(0);
// If largeData is captured by a closure that outlives this function...
globalThis.leakedCallback = () => largeData.length; // LEAK!
}
// GOOD: explicitly null out large references
globalThis.leakedCallback = null;
largeData = null;
// WeakRef allows GC to collect the object even if the ref exists
class ImageCache {
#cache = new Map();
set(key, image) {
this.#cache.set(key, new WeakRef(image));
}
get(key) {
const ref = this.#cache.get(key);
if (!ref) return null;
const image = ref.deref(); // returns undefined if GC'd
if (!image) {
this.#cache.delete(key); // clean up dead entry
return null;
}
return image;
}
}
// Runs a callback AFTER an object is garbage collected
const registry = new FinalizationRegistry((heldValue) => {
console.log(`Object with key ${heldValue} was collected`);
cleanupResource(heldValue);
});
function createResource(key) {
const resource = new SomeResource();
registry.register(resource, key); // register for cleanup notification
return resource;
}
// Caution: cleanup callback runs in unpredictable timing
// Do NOT use for time-sensitive cleanup — use explicit disposal instead
// 1. Forgotten event listeners
const el = document.querySelector('#button');
el.addEventListener('click', handler); // LEAK if el is removed from DOM
// FIX: remove listener when no longer needed
el.removeEventListener('click', handler);
// OR: use { once: true } for one-time listeners
el.addEventListener('click', handler, { once: true });
// OR: use AbortController to remove multiple listeners at once
const ac = new AbortController();
el.addEventListener('click', handler, { signal: ac.signal });
el.addEventListener('focus', handler2, { signal: ac.signal });
ac.abort(); // removes all listeners at once
// 2. Timers holding references
const data = fetchLargeData();
const timer = setInterval(() => {
process(data); // data is kept alive by closure
}, 1000);
// FIX:
clearInterval(timer);
// 3. Closures capturing large scope
function setup() {
const HUGE_ARRAY = new Array(1_000_000);
return function small() {
return HUGE_ARRAY.length; // keeps HUGE_ARRAY alive!
};
}
// FIX: only capture what you need
function setup() {
const HUGE_ARRAY = new Array(1_000_000);
const size = HUGE_ARRAY.length; // extract the value
return function small() {
return size; // HUGE_ARRAY can now be collected
};
}
// 4. Detached DOM nodes
let el = document.querySelector('#container');
const cache = new WeakMap(); // WeakMap — keys are weakly held
cache.set(el, { data: 'important' });
el = null; // but if the DOM node is detached and nobody holds it, WeakMap auto-cleans
// 5. Growing arrays/maps without eviction
class EventBus {
#handlers = new Map(); // grows forever if subscribers never unsubscribe!
on(event, handler) { /* ... */ }
off(event, handler) { /* ... */ } // MUST provide this
}
Each "tick" of the Node.js event loop runs these phases in order:
┌──────────────────────────────────────────────────────┐
│ timers │
│ Executes setTimeout() and setInterval() callbacks │
│ (after their minimum delay — not exactly) │
└──────────────────────┬───────────────────────────────┘
│ process.nextTick + microtasks drain here
┌──────────────────────▼───────────────────────────────┐
│ pending callbacks │
│ I/O callbacks deferred from previous iteration │
│ (e.g., TCP errors) │
└──────────────────────┬───────────────────────────────┘
│ process.nextTick + microtasks drain here
┌──────────────────────▼───────────────────────────────┐
│ idle, prepare │
│ Internal use only │
└──────────────────────┬───────────────────────────────┘
┌──────────────────────▼───────────────────────────────┐
│ poll │
│ Retrieve new I/O events — execute I/O callbacks │
│ (will block here if nothing pending) │
└──────────────────────┬───────────────────────────────┘
│ process.nextTick + microtasks drain here
┌──────────────────────▼───────────────────────────────┐
│ check │
│ setImmediate() callbacks execute here │
└──────────────────────┬───────────────────────────────┘
│ process.nextTick + microtasks drain here
┌──────────────────────▼───────────────────────────────┐
│ close callbacks │
│ e.g., socket.on('close', ...) callbacks │
└──────────────────────────────────────────────────────┘
console.log('1: sync start');
setTimeout(() => console.log('5: setTimeout'), 0);
setImmediate(() => console.log('6: setImmediate'));
Promise.resolve().then(() => console.log('3: Promise.then (microtask)'));
queueMicrotask(() => console.log('4: queueMicrotask'));
process.nextTick(() => console.log('2: nextTick'));
console.log('1b: sync end');
// Output order:
// 1: sync start
// 1b: sync end
// 2: nextTick ← nextTick runs before other microtasks
// 3: Promise.then ← then other microtasks
// 4: queueMicrotask ← queueMicrotask is a microtask
// 5: setTimeout ← macrotask (timers phase)
// 6: setImmediate ← macrotask (check phase)
// NOTE: setTimeout vs setImmediate order is non-deterministic
// UNLESS inside an I/O callback — then setImmediate always comes first
fs.readFile('./file', () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate')); // always first in I/O callback
});
// nextTick runs BEFORE I/O, even before Promises
// Recursive nextTick can starve I/O (starvation attack)
// BAD: recursive nextTick starves event loop
function badRecursion() {
process.nextTick(badRecursion); // I/O NEVER runs!
}
// GOOD for: async-like callback for sync operations
class EventEmitter {
emit(event, data) {
// Emit in next tick to allow current stack to finish
process.nextTick(() => {
this.handlers.get(event)?.forEach(h => h(data));
});
}
}
// PREFER: queueMicrotask (same timing, no nextTick starvation risk)
queueMicrotask(() => doSomething());
// Static import with type assertion
import data from './data.json' with { type: 'json' };
import styles from './theme.css' with { type: 'css' };
import wasm from './module.wasm' with { type: 'webassembly' };
// Dynamic import with attributes
const config = await import('./config.json', { with: { type: 'json' } });
// Node.js — JSON modules require import assertion
import pkg from './package.json' with { type: 'json' };
console.log(pkg.version);
// Bundler support:
// Vite: supported for JSON (built-in) and CSS
// esbuild: JSON supported
// Rollup: JSON plugin
// Webpack: asset modules