modules-runtime.md 19 KB

Modules & Runtime Reference

ESM, CommonJS, dual packages, V8 internals, memory management, and the Node.js event loop in depth.


ESM — ES Modules

import / export Syntax

// 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

// 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 */ }

Dynamic import()

// 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;
}

CommonJS (CJS)

require / module.exports

// 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
}

CJS ↔ ESM Interop

// 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');

Dual Packages — Publish ESM + CJS

package.json "exports" Field

{
  "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"]
}

Conditional Exports — Environment-Specific

{
  "exports": {
    ".": {
      "browser": "./dist/browser.mjs",
      "worker": "./dist/worker.mjs",
      "node": {
        "import": "./dist/node.mjs",
        "require": "./dist/node.cjs"
      },
      "default": "./dist/index.mjs"
    }
  }
}

Dual Package Hazard

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;
}

Tree Shaking

// 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); }

Bundler Comparison — Module Handling

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 Internals — Writing Optimizable Code

Hidden Classes (Shapes)

// 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

Inline Caches (ICs)

// 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
}

JIT Compilation — What Deoptimizes

// 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

Memory Management

Garbage Collection — Mark-and-Sweep

// 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 — Weak References

// 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;
  }
}

FinalizationRegistry — Cleanup After GC

// 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

Common Memory Leaks

// 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
}

Event Loop Deep Dive (Node.js)

Libuv Phases

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            │
└──────────────────────────────────────────────────────┘

process.nextTick vs setImmediate vs queueMicrotask

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
});

process.nextTick — Use Sparingly

// 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());

Import Attributes (ES2024)

// 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