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:
  1. Registers your callback/promise
  2. Starts the operation (delegates to OS or thread pool)
  3. Continues executing your code
  4. 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

javascript
import { 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

javascript
function 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:
javascript
function 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:
javascript
import 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

javascript
import { 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:
javascript
import { 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

javascript
import { 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

javascript
async 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

javascript
function 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

javascript
class 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

javascript
async 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

javascript
function 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

javascript
function 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:
  1. Callbacks: Foundation, error-first convention, can lead to nesting
  2. Promises: Chainable, better error handling, static methods for coordination
  3. async/await: Readable, try/catch error handling, looks synchronous
Key patterns:
  • Use Promise.all for parallel operations
  • Use Promise.allSettled when 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

  1. 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.
  1. 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.
  1. 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.
  1. What causes unhandled promise rejections?
Promises without .catch() or async functions without try/catch. Always ensure every promise chain has error handling.
  1. 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.
All Blogs
Tags:nodejsasyncpromisesasync-awaitcallbacks