Part 5: Asynchronous Programming
Callbacks, Promises, async/await, and Error Handling Patterns
The Nature of Asynchronous Node.js
JavaScript is single-threaded, but Node.js handles thousands of concurrent operations through asynchronous programming. Understanding async patterns is fundamental to writing effective Node.js code.
When you initiate an async operation, Node.js:
- Registers your callback/promise
- Starts the operation (delegates to OS or thread pool)
- Continues executing your code
- Later, when the operation completes, executes your callback
This is fundamentally different from synchronous code where each operation blocks until complete.
The Evolution of Async in JavaScript
1995 2009 2015 2017 | | | | Callbacks → Node.js → ES6 Promises → async/await (callback (Promise (syntactic hell) chains) sugar)
Callbacks: The Foundation
Callbacks are functions passed as arguments to be executed later. Node.js established the error-first callback convention.
Error-First Callback Pattern
javascriptimport { readFile, writeFile } from 'fs'; // Error is always the first parameter readFile('./data.txt', 'utf8', (error, data) => { if (error) { console.error('Failed to read file:', error.message); return; } console.log('File contents:', data); }); // Writing a file writeFile('./output.txt', 'Hello!', (error) => { if (error) { console.error('Failed to write file:', error.message); return; } console.log('File written successfully'); });
Creating Callback-Based Functions
javascriptfunction fetchUserData(userId, callback) { // Simulate async database query setTimeout(() => { if (!userId) { callback(new Error('User ID is required')); return; } const user = { id: userId, name: 'John Doe', email: 'john@example.com' }; callback(null, user); }, 100); } // Usage fetchUserData(123, (error, user) => { if (error) { console.error(error.message); return; } console.log(user); });
Callback Hell (The Pyramid of Doom)
When you need sequential async operations, callbacks nest deeply:
javascript// The problem: hard to read, hard to maintain readFile('./user.json', 'utf8', (err, userData) => { if (err) return handleError(err); const user = JSON.parse(userData); readFile(`./orders/${user.id}.json`, 'utf8', (err, orderData) => { if (err) return handleError(err); const orders = JSON.parse(orderData); readFile(`./products/${orders[0].productId}.json`, 'utf8', (err, productData) => { if (err) return handleError(err); const product = JSON.parse(productData); console.log('User:', user.name); console.log('First order:', orders[0].id); console.log('Product:', product.name); }); }); });
Mitigating Callback Hell
Named Functions:
javascriptfunction handleUser(err, userData) { if (err) return handleError(err); const user = JSON.parse(userData); readFile(`./orders/${user.id}.json`, 'utf8', handleOrders); } function handleOrders(err, orderData) { if (err) return handleError(err); const orders = JSON.parse(orderData); readFile(`./products/${orders[0].productId}.json`, 'utf8', handleProduct); } function handleProduct(err, productData) { if (err) return handleError(err); const product = JSON.parse(productData); console.log('Product:', product.name); } readFile('./user.json', 'utf8', handleUser);
Async Library:
javascriptimport async from 'async'; async.waterfall([ (callback) => { readFile('./user.json', 'utf8', callback); }, (userData, callback) => { const user = JSON.parse(userData); readFile(`./orders/${user.id}.json`, 'utf8', (err, data) => { callback(err, user, data); }); }, (user, orderData, callback) => { const orders = JSON.parse(orderData); readFile(`./products/${orders[0].productId}.json`, 'utf8', callback); } ], (err, productData) => { if (err) return handleError(err); console.log('Product:', JSON.parse(productData).name); });
Promises: Structured Async
Promises represent a value that will be available in the future. They have three states: pending, fulfilled, or rejected.
Promise Fundamentals
javascript// Creating a Promise const myPromise = new Promise((resolve, reject) => { // Async operation setTimeout(() => { const success = true; if (success) { resolve('Operation completed'); } else { reject(new Error('Operation failed')); } }, 100); }); // Consuming a Promise myPromise .then(result => { console.log(result); // 'Operation completed' }) .catch(error => { console.error(error.message); });
Converting Callbacks to Promises
javascriptimport { readFile as readFileCb } from 'fs'; import { promisify } from 'util'; // Manual conversion function readFilePromise(path, encoding) { return new Promise((resolve, reject) => { readFileCb(path, encoding, (error, data) => { if (error) { reject(error); } else { resolve(data); } }); }); } // Using util.promisify const readFile = promisify(readFileCb); // Usage readFile('./data.txt', 'utf8') .then(data => console.log(data)) .catch(error => console.error(error));
Promise Chaining
Promises return new Promises, enabling chaining:
javascriptimport { readFile } from 'fs/promises'; readFile('./user.json', 'utf8') .then(userData => { const user = JSON.parse(userData); return readFile(`./orders/${user.id}.json`, 'utf8'); }) .then(orderData => { const orders = JSON.parse(orderData); return readFile(`./products/${orders[0].productId}.json`, 'utf8'); }) .then(productData => { const product = JSON.parse(productData); console.log('Product:', product.name); }) .catch(error => { console.error('Error:', error.message); });
Promise Static Methods
javascript// Promise.all - Wait for all, fail fast const results = await Promise.all([ fetch('/api/users'), fetch('/api/products'), fetch('/api/orders') ]); // If any fails, entire Promise.all rejects // Promise.allSettled - Wait for all, regardless of outcome const outcomes = await Promise.allSettled([ fetch('/api/users'), fetch('/api/products'), // This might fail fetch('/api/orders') ]); // Returns: [ // { status: 'fulfilled', value: Response }, // { status: 'rejected', reason: Error }, // { status: 'fulfilled', value: Response } // ] // Promise.race - First to complete wins const fastest = await Promise.race([ fetch('/api/server1'), fetch('/api/server2'), fetch('/api/server3') ]); // Promise.any - First to succeed wins (ignores rejections) const firstSuccess = await Promise.any([ fetch('/api/primary'), fetch('/api/backup1'), fetch('/api/backup2') ]); // Only rejects if ALL promises reject // Promise.resolve / Promise.reject - Create settled promises const resolved = Promise.resolve('immediate value'); const rejected = Promise.reject(new Error('immediate error'));
Promise Gotchas
javascript// Gotcha 1: Not returning in .then() fetchUser() .then(user => { fetchOrders(user.id); // Missing return! }) .then(orders => { console.log(orders); // undefined! }); // Correct: fetchUser() .then(user => { return fetchOrders(user.id); }) .then(orders => { console.log(orders); // Works }); // Gotcha 2: Creating promises in loops const users = [1, 2, 3]; // BAD: Sequential, slow for (const id of users) { await fetchUser(id); } // GOOD: Parallel, fast await Promise.all(users.map(id => fetchUser(id))); // Gotcha 3: Unhandled rejections somePromise(); // If this rejects with no .catch(), Node.js warns/crashes // Always handle: somePromise().catch(console.error); // Or globally: process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection:', reason); });
async/await: Modern Async
async/await is syntactic sugar over Promises that makes async code look synchronous.
Basic Usage
javascriptimport { readFile } from 'fs/promises'; async function loadConfiguration() { try { const data = await readFile('./config.json', 'utf8'); const config = JSON.parse(data); return config; } catch (error) { console.error('Failed to load config:', error.message); throw error; } } // An async function always returns a Promise const configPromise = loadConfiguration(); configPromise.then(config => console.log(config)); // Or use it in another async function async function main() { const config = await loadConfiguration(); console.log(config); } main();
Sequential vs Parallel Execution
javascript// Sequential: Each awaits the previous async function sequential() { const user = await fetchUser(1); const orders = await fetchOrders(user.id); const products = await fetchProducts(orders[0].productId); return { user, orders, products }; } // Parallel: All start immediately async function parallel() { const [users, products, orders] = await Promise.all([ fetchUsers(), fetchProducts(), fetchOrders() ]); return { users, products, orders }; } // Mixed: Some parallel, some sequential async function mixed() { // These can run in parallel const [user, settings] = await Promise.all([ fetchUser(1), fetchSettings() ]); // This depends on user, so must be sequential const orders = await fetchOrders(user.id); return { user, settings, orders }; }
Error Handling with async/await
javascript// try/catch for single operations async function getUser(id) { try { const user = await fetchUser(id); return user; } catch (error) { console.error('Failed to fetch user:', error.message); return null; } } // try/catch for multiple operations async function processOrder(orderId) { try { const order = await fetchOrder(orderId); const payment = await processPayment(order); const notification = await sendNotification(order, payment); return { order, payment, notification }; } catch (error) { // Error from any step lands here console.error('Order processing failed:', error.message); throw error; } } // Handling errors from Promise.all async function fetchAllData() { try { const [users, products] = await Promise.all([ fetchUsers(), fetchProducts() ]); return { users, products }; } catch (error) { // If either fails, we catch it here console.error('Failed to fetch data:', error.message); throw error; } } // Handling partial failures with Promise.allSettled async function fetchWithFallbacks() { const results = await Promise.allSettled([ fetchFromPrimary(), fetchFromBackup() ]); const successful = results .filter(r => r.status === 'fulfilled') .map(r => r.value); const failed = results .filter(r => r.status === 'rejected') .map(r => r.reason); if (failed.length > 0) { console.warn('Some requests failed:', failed); } return successful; }
Async Iteration
javascript// Async generators async function* fetchPages(url) { let page = 1; while (true) { const response = await fetch(`${url}?page=${page}`); const data = await response.json(); if (data.items.length === 0) break; yield data.items; page++; } } // Consuming async generators async function getAllItems() { const items = []; for await (const page of fetchPages('/api/items')) { items.push(...page); } return items; } // Async iteration over streams import { createReadStream } from 'fs'; import { createInterface } from 'readline'; async function processLines(filePath) { const fileStream = createReadStream(filePath); const lines = createInterface({ input: fileStream }); for await (const line of lines) { console.log('Line:', line); } }
Top-Level Await (ES Modules)
javascript// In ES modules, await works at the top level // config.mjs import { readFile } from 'fs/promises'; const configData = await readFile('./config.json', 'utf8'); export const config = JSON.parse(configData); // app.mjs import { config } from './config.mjs'; // config is already resolved console.log(config.port);
Error Handling Patterns
Centralized Error Handling
javascript// errors.js class AppError extends Error { constructor(message, statusCode, isOperational = true) { super(message); this.statusCode = statusCode; this.isOperational = isOperational; Error.captureStackTrace(this, this.constructor); } } class NotFoundError extends AppError { constructor(resource) { super(`${resource} not found`, 404); } } class ValidationError extends AppError { constructor(message) { super(message, 400); } } class DatabaseError extends AppError { constructor(message) { super(message, 500, false); // Not operational, should alert } } export { AppError, NotFoundError, ValidationError, DatabaseError };
Error Handling Middleware Pattern
javascript// Async wrapper to catch errors function asyncHandler(fn) { return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; } // Usage app.get('/users/:id', asyncHandler(async (req, res) => { const user = await User.findById(req.params.id); if (!user) { throw new NotFoundError('User'); } res.json(user); })); // Error handling middleware app.use((error, req, res, next) => { console.error(error); if (error.isOperational) { return res.status(error.statusCode).json({ error: error.message }); } // Programming or unknown error return res.status(500).json({ error: 'Internal server error' }); });
Retry Pattern
javascriptasync function retry(fn, maxAttempts = 3, delay = 1000) { let lastError; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn(); } catch (error) { lastError = error; console.log(`Attempt ${attempt} failed: ${error.message}`); if (attempt < maxAttempts) { await new Promise(r => setTimeout(r, delay * attempt)); } } } throw lastError; } // Usage const data = await retry( () => fetch('https://api.example.com/data'), 3, // max attempts 1000 // initial delay (exponential backoff) );
Timeout Pattern
javascriptfunction withTimeout(promise, ms) { const timeout = new Promise((_, reject) => { setTimeout(() => reject(new Error('Operation timed out')), ms); }); return Promise.race([promise, timeout]); } // Usage try { const data = await withTimeout( fetch('https://slow-api.example.com'), 5000 // 5 second timeout ); } catch (error) { if (error.message === 'Operation timed out') { console.log('Request timed out'); } } // AbortController (modern approach) async function fetchWithAbort(url, timeoutMs) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal }); return response; } finally { clearTimeout(timeoutId); } }
Circuit Breaker Pattern
javascriptclass CircuitBreaker { constructor(fn, options = {}) { this.fn = fn; this.failureThreshold = options.failureThreshold || 5; this.resetTimeout = options.resetTimeout || 30000; this.state = 'CLOSED'; this.failures = 0; this.lastFailure = null; } async call(...args) { if (this.state === 'OPEN') { if (Date.now() - this.lastFailure >= this.resetTimeout) { this.state = 'HALF-OPEN'; } else { throw new Error('Circuit breaker is OPEN'); } } try { const result = await this.fn(...args); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } } onSuccess() { this.failures = 0; this.state = 'CLOSED'; } onFailure() { this.failures++; this.lastFailure = Date.now(); if (this.failures >= this.failureThreshold) { this.state = 'OPEN'; } } } // Usage const breaker = new CircuitBreaker( () => fetch('https://external-api.com/data'), { failureThreshold: 3, resetTimeout: 10000 } ); try { const data = await breaker.call(); } catch (error) { if (error.message === 'Circuit breaker is OPEN') { // Return cached data or fallback } }
Practical Patterns
Limiting Concurrency
javascriptasync function mapWithLimit(items, limit, fn) { const results = []; const executing = []; for (const item of items) { const promise = fn(item).then(result => { executing.splice(executing.indexOf(promise), 1); return result; }); results.push(promise); executing.push(promise); if (executing.length >= limit) { await Promise.race(executing); } } return Promise.all(results); } // Usage: Process 100 items, max 5 at a time const results = await mapWithLimit( items, 5, async (item) => processItem(item) );
Debouncing Async Functions
javascriptfunction debounceAsync(fn, wait) { let timeoutId; let pending; return function (...args) { clearTimeout(timeoutId); return new Promise((resolve, reject) => { timeoutId = setTimeout(async () => { try { const result = await fn.apply(this, args); resolve(result); } catch (error) { reject(error); } }, wait); }); }; } // Usage const debouncedSearch = debounceAsync(async (query) => { return fetch(`/api/search?q=${query}`); }, 300);
Caching Async Results
javascriptfunction memoizeAsync(fn, keyFn = JSON.stringify) { const cache = new Map(); return async function (...args) { const key = keyFn(args); if (cache.has(key)) { return cache.get(key); } const promise = fn.apply(this, args); cache.set(key, promise); try { return await promise; } catch (error) { cache.delete(key); // Don't cache failures throw error; } }; } // Usage const memoizedFetch = memoizeAsync(async (url) => { const response = await fetch(url); return response.json(); });
Summary
Async programming in Node.js has evolved:
- Callbacks: Foundation, error-first convention, can lead to nesting
- Promises: Chainable, better error handling, static methods for coordination
- async/await: Readable, try/catch error handling, looks synchronous
Key patterns:
- Use
Promise.allfor parallel operations - Use
Promise.allSettledwhen you need all results regardless of failures - Always handle errors with try/catch or .catch()
- Consider retry, timeout, and circuit breaker for resilience
Questions to Think About
- When would you use callbacks over Promises?
When interfacing with callback-based APIs or when you need fine-grained control over execution. Most new code should use Promises or async/await.
- What is the difference between returning and awaiting in an async function?
return promise returns the promise immediately. return await promise waits for resolution first. They behave the same for success, but return await can catch errors in try/catch.- How do you handle errors from Promise.all?
Either wrap in try/catch or use .catch(). If any promise rejects, Promise.all rejects immediately. Use Promise.allSettled if you need all results regardless of failures.
- What causes unhandled promise rejections?
Promises without .catch() or async functions without try/catch. Always ensure every promise chain has error handling.
- How do you avoid blocking the event loop with async code?
Use async operations for I/O. For CPU-intensive work, offload to Worker Threads. Limit concurrency to prevent resource exhaustion.
Next: Part 6 - The Event Loop Internals, where we explore the phases of the event loop, microtasks, and timing behavior in depth.