JavaScript Concurrency & Architecture: Async, Modules, and Error Handling
Asynchronous programming is the heartbeat of JavaScript. This comprehensive guide dissects the runtime's concurrency model—explaining the Event Loop, Microtasks, and the transition from Callbacks to Async/Await. Beyond logic, we define structural best practices: implementing defensive error handling strategies and organizing codebases using modern ES Modules.
Error Handling
Error Types
JavaScript has several built-in error types for different failure scenarios, each providing specific context about what went wrong during execution.
┌──────────────────────────────────────────────────┐
│ Error │ ← Base type
├──────────────────────────────────────────────────┤
│ SyntaxError │ Invalid code syntax │
│ ReferenceError │ Undefined variable access │
│ TypeError │ Wrong type operation │
│ RangeError │ Number out of valid range │
│ URIError │ Invalid URI functions │
│ EvalError │ eval() related (legacy) │
└──────────────────────────────────────────────────┘
Error Object
The Error object contains essential debugging information including message (description), name (error type), and stack (call trace), forming the base for all error types.
const err = new Error('Something went wrong'); console.log(err.name); // 'Error' console.log(err.message); // 'Something went wrong' console.log(err.stack); // Full stack trace with line numbers
try-catch
The try-catch block handles runtime errors gracefully by attempting code in try and catching any thrown errors in catch, preventing application crashes.
try { const data = JSON.parse('invalid json'); } catch (error) { console.error('Parse failed:', error.message); // Handle gracefully instead of crashing } console.log('App continues running');
try-catch-finally
The finally block executes regardless of whether an error occurred, guaranteed to run even after return statements, ideal for cleanup operations like closing resources.
function readFile() { const file = openFile('data.txt'); try { return processFile(file); } catch (error) { console.error('Processing failed'); return null; } finally { file.close(); // ALWAYS runs - cleanup guaranteed } }
throw Statement
The throw statement generates custom errors, stopping normal execution and passing control to the nearest catch block; you can throw any value but Error objects are preferred.
function divide(a, b) { if (b === 0) { throw new Error('Division by zero'); // Stop execution } if (typeof a !== 'number') { throw new TypeError('Arguments must be numbers'); } return a / b; }
Custom Errors
Custom errors extend the base Error class to create domain-specific error types with additional properties, improving error categorization and handling specificity.
class ValidationError extends Error { constructor(field, message) { super(message); this.name = 'ValidationError'; this.field = field; } } throw new ValidationError('email', 'Invalid email format'); // Catch: if (error instanceof ValidationError) {...}
Error Subclassing
Error subclassing creates hierarchies of related errors, enabling precise error handling with instanceof checks while maintaining proper prototype chain and stack traces.
class HttpError extends Error { constructor(status, message) { super(message); this.name = 'HttpError'; this.status = status; } } class NotFoundError extends HttpError { constructor(resource) { super(404, `${resource} not found`); this.name = 'NotFoundError'; } } // Hierarchy: Error → HttpError → NotFoundError
Stack Traces
Stack traces show the sequence of function calls leading to an error, invaluable for debugging; they're automatically captured in the stack property when an Error is created.
function a() { b(); } function b() { c(); } function c() { throw new Error('Deep error'); } try { a(); } catch (e) { console.log(e.stack); } /* Error: Deep error at c (file.js:3) at b (file.js:2) at a (file.js:1) ← Call stack trace */
Error.cause
The cause property (ES2022) chains errors together, preserving the original error when re-throwing with additional context, creating a linked list of error causes.
async function fetchUser(id) { try { const response = await fetch(`/api/users/${id}`); return await response.json(); } catch (error) { throw new Error(`Failed to fetch user ${id}`, { cause: error }); } } // Later: error.cause contains original fetch error
AggregateError
AggregateError (ES2021) wraps multiple errors into one, useful for operations that can fail in multiple ways simultaneously like Promise.any() when all promises reject.
const errors = [ new Error('DB connection failed'), new Error('Cache unavailable'), new Error('API timeout') ]; const aggError = new AggregateError(errors, 'All services failed'); console.log(aggError.errors); // Array of individual errors // Used by: Promise.any() when all promises reject
Error Handling Best Practices
Follow these patterns for robust error handling: catch specific errors, fail fast, log with context, clean up resources, never swallow errors silently, and use error boundaries in UI code.
// ✅ GOOD PRACTICES async function fetchData(url) { try { const res = await fetch(url); if (!res.ok) throw new HttpError(res.status, 'Fetch failed'); return await res.json(); } catch (error) { logger.error('fetchData failed', { url, error }); // Log context if (error instanceof HttpError) { // Handle known errors specifically return { fallback: true }; } throw error; // Re-throw unknown errors } } // ❌ AVOID: catch(e) {} // Silent swallow // ❌ AVOID: catch(e) { throw e } // Pointless // ❌ AVOID: Catching errors you can't handle
Asynchronous JavaScript
Synchronous vs Asynchronous
Synchronous code executes line-by-line, blocking further execution until each operation completes, while asynchronous code allows operations to run in the background, enabling the program to continue without waiting. This is crucial in JavaScript for handling I/O operations, network requests, and user interactions without freezing the UI.
// Synchronous - blocks execution const data = readFileSync('file.txt'); // waits here // Asynchronous - non-blocking readFile('file.txt', (data) => { }); // continues immediately console.log('This runs first!');
Call Stack
The call stack is a LIFO (Last In, First Out) data structure that tracks function execution; when a function is called, it's pushed onto the stack, and when it returns, it's popped off.
┌─────────────────┐
│ multiply() │ ← Top (current execution)
├─────────────────┤
│ calculate() │
├─────────────────┤
│ main() │
├─────────────────┤
│ <global> │ ← Bottom
└─────────────────┘
Event Loop
The event loop is JavaScript's concurrency mechanism that continuously monitors the call stack and callback queues, pushing queued callbacks to the stack when it's empty, enabling non-blocking behavior in a single-threaded environment.
┌───────────────────────────┐
┌──►│ Call Stack │
│ └───────────┬───────────────┘
│ │ (empty?)
│ ┌───────────▼───────────────┐
│ │ Event Loop │◄──────────┐
│ └───────────┬───────────────┘ │
│ │ │
│ ┌───────────▼───────────────┐ ┌───────┴───────┐
│ │ Microtask Queue │ │ Web APIs │
│ └───────────┬───────────────┘ │ (setTimeout, │
│ │ │ fetch, etc) │
│ ┌───────────▼───────────────┐ └───────────────┘
└───┤ Callback Queue │
└───────────────────────────┘
Callback Queue
The callback queue (also called task queue or macrotask queue) holds callbacks from Web APIs like setTimeout, DOM events, and I/O operations, which are processed by the event loop after the call stack is empty and microtasks are completed.
console.log('1'); setTimeout(() => console.log('2'), 0); // Goes to callback queue console.log('3'); // Output: 1, 3, 2
Microtask Queue
The microtask queue has higher priority than the callback queue and contains callbacks from Promises (.then, .catch, .finally) and MutationObserver; all microtasks are processed before any macrotask.
console.log('1'); setTimeout(() => console.log('2'), 0); // Macrotask Promise.resolve().then(() => console.log('3')); // Microtask console.log('4'); // Output: 1, 4, 3, 2
Task Queue
Task queue is another name for the callback/macrotask queue, containing tasks scheduled by setTimeout, setInterval, I/O callbacks, and UI rendering events, processed one at a time per event loop iteration after clearing the microtask queue.
Priority Order:
┌─────────────────────────────────────┐
│ 1. Call Stack (synchronous code) │ ← Highest
├─────────────────────────────────────┤
│ 2. Microtask Queue (Promises) │
├─────────────────────────────────────┤
│ 3. Task Queue (setTimeout, etc.) │ ← Lowest
└─────────────────────────────────────┘
Callbacks
A callback is a function passed as an argument to another function, to be executed later when an asynchronous operation completes or an event occurs.
function fetchData(url, callback) { // Simulate async operation setTimeout(() => { const data = { user: 'John' }; callback(null, data); // Node.js convention: error-first }, 1000); } fetchData('/api/user', (err, data) => { if (err) return console.error(err); console.log(data); });
Callback Hell
Callback hell (or "pyramid of doom") occurs when multiple nested callbacks create deeply indented, hard-to-read, and hard-to-maintain code, commonly seen when handling sequential async operations.
// ❌ Callback Hell getUser(userId, (err, user) => { getOrders(user.id, (err, orders) => { getOrderDetails(orders[0].id, (err, details) => { getShipping(details.shippingId, (err, shipping) => { // 4 levels deep... nightmare! }); }); }); }); // ✅ Solution: Use Promises or async/await
Promises
A Promise is an object representing the eventual completion or failure of an asynchronous operation, providing a cleaner alternative to callbacks with chainable methods for handling success and error cases.
const promise = new Promise((resolve, reject) => { const success = true; setTimeout(() => { success ? resolve('Data loaded!') : reject('Error!'); }, 1000); }); promise .then(data => console.log(data)) .catch(err => console.error(err));
Promise States
A Promise exists in one of three states: pending (initial state, neither fulfilled nor rejected), fulfilled (operation completed successfully), or rejected (operation failed); once settled, a promise cannot change state.
┌──────────────┐
│ PENDING │
└──────┬───────┘
│
┌───────────────┴───────────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ FULFILLED │ │ REJECTED │
│ (resolved) │ │ (error) │
└──────────────┘ └──────────────┘
◄─────────── SETTLED (immutable) ───────────►
Promise Constructor
The Promise constructor takes an executor function with two parameters: resolve (call on success) and reject (call on failure), and the executor runs immediately when the promise is created.
const myPromise = new Promise((resolve, reject) => { // Executor runs immediately doAsyncOperation((err, result) => { if (err) { reject(err); // Transition to rejected } else { resolve(result); // Transition to fulfilled } }); });
then, catch, finally
These are Promise instance methods: then() handles fulfillment (and optionally rejection), catch() handles rejection only, and finally() executes regardless of outcome—all return new Promises for chaining.
fetchUser(1) .then(user => { console.log('Success:', user); return user.id; }) .catch(err => { console.error('Error:', err); }) .finally(() => { console.log('Cleanup: Always runs'); hideLoadingSpinner(); });
Promise.resolve, Promise.reject
Promise.resolve(value) returns a Promise fulfilled with the given value (or the same promise if value is a promise), while Promise.reject(reason) returns a Promise rejected with the given reason—useful for creating immediate promise results.
// Create immediately resolved/rejected promises const resolved = Promise.resolve(42); const rejected = Promise.reject(new Error('Failed')); // Useful for normalizing values to promises function maybeAsync(value) { return Promise.resolve(value); // Always returns a promise }
Promise.all
Promise.all() takes an iterable of promises and returns a single promise that fulfills with an array of results when all input promises fulfill, or rejects immediately if any promise rejects (fail-fast behavior).
const promises = [ fetch('/api/users'), fetch('/api/posts'), fetch('/api/comments') ]; Promise.all(promises) .then(([users, posts, comments]) => { // All succeeded - array of results console.log('All data loaded!'); }) .catch(err => { // ANY one failed - first rejection console.error('One request failed:', err); });
Promise.allSettled
Promise.allSettled() waits for all promises to settle (fulfill or reject) and returns an array of objects describing each outcome, never short-circuiting—useful when you need all results regardless of individual failures.
const promises = [ Promise.resolve('Success'), Promise.reject('Error'), Promise.resolve('Another success') ]; Promise.allSettled(promises).then(results => { console.log(results); // [ // { status: 'fulfilled', value: 'Success' }, // { status: 'rejected', reason: 'Error' }, // { status: 'fulfilled', value: 'Another success' } // ] });
Promise.race
Promise.race() returns a promise that settles with the first promise to settle (either fulfill or reject), useful for implementing timeouts or taking the fastest response from multiple sources.
// Timeout pattern function fetchWithTimeout(url, ms) { return Promise.race([ fetch(url), new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout!')), ms) ) ]); } fetchWithTimeout('/api/data', 5000) .then(data => console.log(data)) .catch(err => console.error(err)); // 'Timeout!' if > 5s
Promise.any
Promise.any() returns the first promise to fulfill, ignoring rejections unless all promises reject (then throws AggregateError)—useful for redundant requests where you only need one successful result.
const mirrors = [ fetch('https://mirror1.com/file'), fetch('https://mirror2.com/file'), fetch('https://mirror3.com/file') ]; Promise.any(mirrors) .then(response => { console.log('First successful:', response.url); }) .catch(err => { // AggregateError: All promises rejected console.log(err.errors); // Array of all rejection reasons });
Promise Chaining
Promise chaining leverages the fact that .then() returns a new promise, allowing sequential async operations to be written as a flat chain rather than nested callbacks.
fetchUser(userId) .then(user => fetchOrders(user.id)) // Returns promise .then(orders => fetchDetails(orders[0])) // Returns promise .then(details => { console.log('Final result:', details); return details; }) .catch(err => console.error(err)); // Catches any error in chain
Error Handling in Promises
Errors in promises propagate down the chain until caught; use .catch() for centralized error handling, and remember that errors thrown inside .then() create rejected promises.
fetchData() .then(data => { if (!data.valid) { throw new Error('Invalid data'); // Becomes rejection } return processData(data); }) .then(result => saveData(result)) .catch(err => { // Catches: network errors, thrown errors, rejections console.error('Pipeline failed:', err.message); }) .then(() => { // Recovery: chain continues after catch console.log('Continuing...'); });
async/await
async/await is syntactic sugar over Promises that allows writing asynchronous code in a synchronous style, making it more readable and easier to debug while maintaining non-blocking behavior.
// Promise chain function getUser() { return fetch('/api/user') .then(res => res.json()) .then(user => fetch(`/api/posts/${user.id}`)) .then(res => res.json()); } // async/await - same logic, cleaner syntax async function getUser() { const res = await fetch('/api/user'); const user = await res.json(); const postsRes = await fetch(`/api/posts/${user.id}`); return postsRes.json(); }
async Functions
An async function always returns a Promise: returned values are wrapped in Promise.resolve(), thrown errors become rejections, and it enables the use of await inside its body.
async function example() { return 'Hello'; // Wrapped in Promise.resolve('Hello') } async function failing() { throw new Error('Oops'); // Returns rejected promise } example().then(console.log); // 'Hello' failing().catch(console.error); // Error: Oops
await Keyword
The await keyword pauses async function execution until the Promise settles, returning the fulfilled value or throwing the rejected reason; it can only be used inside async functions (or with top-level await in modules).
async function process() { console.log('Start'); const result = await somePromise(); // Pauses here // Execution resumes when promise settles console.log('Result:', result); // await with non-promise wraps in Promise.resolve const value = await 42; // Works, value = 42 }
Error Handling with async/await
Use try/catch blocks for error handling with async/await, providing a familiar synchronous-style error handling pattern for asynchronous code.
async function fetchUserData(id) { try { const response = await fetch(`/api/users/${id}`); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); return data; } catch (err) { console.error('Fetch failed:', err.message); throw err; // Re-throw or handle } finally { console.log('Cleanup'); } }
Top-level await
Top-level await allows using await outside async functions at the module level (ES2022+), enabling modules to act as async boundaries and blocking dependent modules until promises resolve.
// config.js (ES Module) const response = await fetch('/api/config'); export const config = await response.json(); // app.js import { config } from './config.js'; // Waits for config to load console.log(config); // Guaranteed to be ready
Parallel vs Sequential Execution
Sequential execution awaits each promise one after another, while parallel execution starts all promises simultaneously using Promise.all()—choose based on dependency requirements and performance needs.
// ❌ Sequential: 3 seconds total (1+1+1) async function sequential() { const a = await delay(1000); // wait 1s const b = await delay(1000); // wait 1s const c = await delay(1000); // wait 1s } // ✅ Parallel: 1 second total (all at once) async function parallel() { const [a, b, c] = await Promise.all([ delay(1000), delay(1000), delay(1000) ]); }
Promise Anti-patterns
Common promise anti-patterns include unnecessary nesting, forgetting to return promises in chains, using async without await, and the "Promise constructor anti-pattern" of wrapping existing promises.
// ❌ Anti-pattern: Promise constructor wrapping promise new Promise((resolve) => { fetch(url).then(resolve); // Unnecessary! }); // ✅ Correct fetch(url); // ❌ Anti-pattern: Forgotten return .then(user => { fetchOrders(user.id); // Promise not returned! }) // ✅ Correct .then(user => fetchOrders(user.id)) // ❌ Anti-pattern: async without await async function getData() { return fetch(url); // async keyword unnecessary }
Timers
setTimeout
setTimeout() schedules a callback function to execute once after a specified delay in milliseconds; it returns a timer ID that can be used to cancel the scheduled execution.
const timerId = setTimeout(() => { console.log('Executed after 2 seconds'); }, 2000); // With arguments setTimeout((a, b) => { console.log(a + b); // 30 }, 1000, 10, 20); // Minimum delay is ~4ms (browser throttling) setTimeout(callback, 0); // Deferred, not immediate
setInterval
setInterval() repeatedly executes a callback at fixed intervals (in milliseconds) until cleared, returning a timer ID; note that intervals can drift if callback execution takes longer than the interval.
let count = 0; const intervalId = setInterval(() => { count++; console.log(`Tick ${count}`); if (count >= 5) { clearInterval(intervalId); // Stop after 5 ticks } }, 1000);
clearTimeout
clearTimeout() cancels a timeout previously scheduled with setTimeout() using the returned timer ID, preventing the callback from executing if called before the delay expires.
const timerId = setTimeout(() => { console.log('This will NOT run'); }, 5000); // Cancel before execution clearTimeout(timerId); // Common pattern: debouncing let debounceTimer; function debounce(fn, delay) { clearTimeout(debounceTimer); debounceTimer = setTimeout(fn, delay); }
clearInterval
clearInterval() stops a recurring timer created by setInterval(), preventing further callback executions; always clear intervals when no longer needed to prevent memory leaks.
// Poll until condition met const pollId = setInterval(async () => { const status = await checkStatus(); if (status === 'complete') { clearInterval(pollId); // Stop polling console.log('Done!'); } }, 1000); // Cleanup on component unmount (React pattern) useEffect(() => { const id = setInterval(tick, 1000); return () => clearInterval(id); // Cleanup }, []);
requestAnimationFrame
requestAnimationFrame() schedules a callback before the next browser repaint (typically 60fps), providing the optimal way to perform smooth animations synchronized with the display refresh rate.
function animate(timestamp) { // Update animation state element.style.transform = `translateX(${position}px)`; position += 2; if (position < 500) { requestAnimationFrame(animate); // Schedule next frame } } // Start animation const frameId = requestAnimationFrame(animate); // Preferred over setInterval for animations: // - Syncs with display refresh // - Pauses in background tabs // - Better performance
cancelAnimationFrame
cancelAnimationFrame() cancels a scheduled animation frame callback using the ID returned by requestAnimationFrame(), useful for stopping animations or cleanup.
let animationId; let isRunning = false; function startAnimation() { isRunning = true; function loop(timestamp) { if (!isRunning) return; updateAnimation(timestamp); animationId = requestAnimationFrame(loop); } animationId = requestAnimationFrame(loop); } function stopAnimation() { isRunning = false; cancelAnimationFrame(animationId); }
setImmediate (Node.js)
setImmediate() schedules a callback to execute in the next iteration of the event loop, after I/O events but before timers; it's Node.js-specific and similar to setTimeout(fn, 0) but with different timing.
// Node.js only setImmediate(() => { console.log('setImmediate'); }); setTimeout(() => { console.log('setTimeout'); }, 0); // Order may vary in main module // But inside I/O callback, setImmediate always first const fs = require('fs'); fs.readFile('file.txt', () => { setImmediate(() => console.log('1: setImmediate')); setTimeout(() => console.log('2: setTimeout'), 0); // Always: 1, 2 });
process.nextTick (Node.js)
process.nextTick() queues a callback to execute immediately after the current operation completes, before the event loop continues; it has higher priority than microtasks and can starve I/O if overused.
// Node.js execution order console.log('1: sync'); process.nextTick(() => console.log('2: nextTick')); Promise.resolve().then(() => console.log('3: microtask')); setTimeout(() => console.log('4: timeout'), 0); setImmediate(() => console.log('5: immediate')); console.log('6: sync'); // Output: 1, 6, 2, 3, 4, 5
┌────────────────────────────────────┐
│ process.nextTick queue │ ← Highest priority
├────────────────────────────────────┤
│ Microtask queue (Promises) │
├────────────────────────────────────┤
│ Timers (setTimeout, setInterval) │
├────────────────────────────────────┤
│ I/O callbacks │
├────────────────────────────────────┤
│ setImmediate │ ← After I/O
└────────────────────────────────────┘
Modules
Script vs Module
Scripts share a global scope and execute immediately, while modules have their own scope, support import/export, use strict mode by default, and are deferred (executed after HTML parsing).
<!-- Script: Global scope, immediate execution --> <script src="app.js"></script> <!-- Module: Own scope, deferred, strict mode --> <script type="module" src="app.js"></script>
| Feature | Script | Module |
|---|---|---|
| Scope | Global | Module-local |
| Strict mode | Optional | Always |
Top-level this | window | undefined |
import/export | No | Yes |
| Loading | Blocking | Deferred |
ES Modules (ESM)
ES Modules are JavaScript's official standard module system (ES2015+), using import/export syntax with static analysis capabilities enabling tree-shaking and providing better tooling support than CommonJS.
// math.js - ES Module export const PI = 3.14159; export function add(a, b) { return a + b; } export default class Calculator { } // app.js - Importing import Calculator, { PI, add } from './math.js';
import Statement
The import statement loads bindings exported from another module, supporting named imports, default imports, namespace imports, and side-effect-only imports; imports are hoisted and read-only.
// Named imports import { useState, useEffect } from 'react'; // Default import import React from 'react'; // Namespace import (all exports as object) import * as utils from './utils.js'; // Renaming import { createStore as makeStore } from 'redux'; // Side-effect import (just execute module) import './polyfills.js'; // Combined import React, { useState } from 'react';
export Statement
The export statement exposes module bindings to other modules; you can export declarations inline, export separately, rename exports, and have one default export plus multiple named exports per module.
// Named exports - inline export const name = 'Module'; export function greet() { } export class User { } // Named exports - separate const a = 1, b = 2; export { a, b }; // Renaming exports export { a as alpha, b as beta }; // Default export export default function main() { } // or export { main as default };
Default Exports vs Named Exports
Default exports allow importing with any name and are limited to one per module (ideal for main functionality), while named exports require exact names (or aliases) and allow multiple exports (ideal for utilities).
// utils.js - Named exports preferred for utilities export function formatDate() { } export function formatNumber() { } // User.js - Default export for primary class/function export default class User { } // Importing import User from './User.js'; // Default: any name import { formatDate, formatNumber } from './utils.js'; // Named: exact // ⚠️ Default export anti-pattern (harder refactoring) export default { formatDate, formatNumber }; // Avoid!
Re-exporting
Re-exporting allows a module to export bindings from other modules, useful for creating barrel files (index.js) that aggregate and expose multiple module exports from a single entry point.
// components/Button.js export const Button = () => { }; // components/Input.js export const Input = () => { }; // components/index.js (barrel file) export { Button } from './Button.js'; export { Input } from './Input.js'; export * from './Form.js'; // Re-export all export { default as Modal } from './Modal.js'; // Re-export default // Usage - clean imports import { Button, Input, Modal } from './components';
import.meta
import.meta is a meta-object containing context about the current module, providing properties like url (module's URL) and environment-specific metadata; it's only available in ES modules.
// Get current module's URL console.log(import.meta.url); // "file:///path/to/module.js" or "https://..." // Resolve relative paths const imagePath = new URL('./image.png', import.meta.url); // Vite-specific console.log(import.meta.env.MODE); // 'development' console.log(import.meta.env.VITE_API_URL); // Custom env var // Node.js (experimental) import.meta.resolve('./other.js'); // Resolve module path
Dynamic Imports
Dynamic imports using import() load modules at runtime, returning a Promise, enabling code-splitting, lazy-loading, and conditional module loading that's not possible with static imports.
// Lazy load on user action button.addEventListener('click', async () => { const { showModal } = await import('./modal.js'); showModal(); }); // Conditional loading const locale = navigator.language; const translations = await import(`./i18n/${locale}.js`); // With error handling try { const module = await import('./optional-feature.js'); } catch (err) { console.log('Feature not available'); }
import()
The import() function-like syntax returns a Promise resolving to the module's namespace object containing all exports; it can be used anywhere (not just at top level) and accepts expressions for the module path.
// Returns Promise<ModuleNamespace> import('./math.js').then(module => { console.log(module.add(2, 3)); console.log(module.default); // Default export }); // With destructuring const { add, subtract } = await import('./math.js'); // Dynamic path (not possible with static import) const moduleName = 'utils'; const utils = await import(`./${moduleName}.js`);
CommonJS (require, module.exports)
CommonJS is Node.js's original module system using synchronous require() for imports and module.exports or exports for exports; it's still widely used in Node.js but is being superseded by ESM.
// math.js - Exporting const PI = 3.14159; function add(a, b) { return a + b; } module.exports = { PI, add }; // or module.exports.PI = PI; module.exports.add = add; // or shorthand exports.PI = PI; // ⚠️ Don't reassign exports directly // app.js - Importing const { PI, add } = require('./math.js'); const math = require('./math.js'); console.log(math.PI);
Module Systems Comparison
ESM uses static, asynchronous imports with live bindings, while CommonJS uses dynamic, synchronous imports with value copies; ESM is the standard for browsers and modern Node.js, CommonJS remains common in legacy Node.js code.
┌─────────────────┬────────────────────┬────────────────────┐
│ Feature │ ESM │ CommonJS │
├─────────────────┼────────────────────┼────────────────────┤
│ Syntax │ import/export │ require/exports │
│ Loading │ Async │ Sync │
│ Analysis │ Static │ Dynamic │
│ Binding │ Live (reference) │ Copy (value) │
│ Top-level await │ Yes │ No │
│ Tree-shaking │ Yes │ Limited │
│ Browser │ Native │ Bundler needed │
│ File extension │ .mjs / "type":"module" │ .cjs / default │
└─────────────────┴────────────────────┴────────────────────┘
Module Loading
Module loading involves resolution (finding the file), fetching (downloading/reading), parsing, and execution; browsers load ESM asynchronously with parallel fetching, while Node.js can load both ESM and CommonJS with different algorithms.
ESM Loading Phases:
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Construction │ → │ Instantiation │ → │ Evaluation │
│ (parse, fetch)│ │ (link imports)│ │ (run code) │
└───────────────┘ └───────────────┘ └───────────────┘
<!-- Preload for performance -->
<link rel="modulepreload" href="./module.js">
Circular Dependencies
Circular dependencies occur when module A imports B and B imports A; ESM handles this better than CommonJS due to live bindings, but you should generally restructure code to avoid cycles.
// ⚠️ Circular dependency example // a.js import { b } from './b.js'; export const a = 'A'; console.log(b); // Works in ESM (live binding) // b.js import { a } from './a.js'; export const b = 'B'; console.log(a); // May be undefined during initialization! // ✅ Solutions: // 1. Restructure - extract shared code to c.js // 2. Lazy access - use functions that access at runtime // 3. Dependency injection
Module Bundling Concepts
Module bundlers (Webpack, Rollup, esbuild) combine multiple modules into fewer files for production, performing tree-shaking (dead code elimination), code-splitting, minification, and transpilation.
┌──────────────────────────────────────────────────┐
│ Source Modules │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ A.js│ │ B.js│ │ C.js│ │ D.js│ │ E.js│ │
│ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ │
│ └────────┴────────┴────────┴────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Bundler │ │
│ │ (Webpack/Vite) │ │
│ └────────┬───────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌──────────┐ ┌─────────┐ │
│ │main.js │ │vendor.js │ │chunk.js │ │
│ │(app) │ │(libs) │ │(lazy) │ │
│ └─────────┘ └──────────┘ └─────────┘ │
└──────────────────────────────────────────────────┘
Key optimizations:
• Tree-shaking: Remove unused exports
• Code-splitting: Lazy-load routes/features
• Minification: Reduce file size
• Hashing: Cache busting