Node.js & Express

Asynchronous Programming in Node.js

20 min Lesson 4 of 40

Understanding Asynchronous Programming

Asynchronous programming is fundamental to Node.js. It allows Node.js to handle multiple operations concurrently without blocking the execution thread, making it perfect for I/O-intensive applications.

Key Concept: Node.js is single-threaded but uses asynchronous operations to handle concurrent tasks efficiently through the event loop.

The Event Loop

The event loop is the heart of Node.js's asynchronous architecture. It allows Node.js to perform non-blocking operations despite JavaScript being single-threaded.

How the Event Loop Works

console.log('Start'); setTimeout(() => { console.log('Timeout callback'); }, 0); Promise.resolve().then(() => { console.log('Promise callback'); }); console.log('End'); /* Output order: Start End Promise callback Timeout callback */

Execution Order Explanation:

  1. Synchronous code runs first: "Start" and "End"
  2. Microtasks (Promises) run next: "Promise callback"
  3. Macrotasks (setTimeout, setInterval) run last: "Timeout callback"
Tip: Promises have higher priority than setTimeout/setInterval in the event loop. They execute in the microtask queue before macrotasks.

Callbacks: The Traditional Approach

Callbacks are functions passed as arguments to be executed later when an operation completes:

const fs = require('fs'); // Reading a file with callback fs.readFile('data.txt', 'utf8', (error, data) => { if (error) { console.error('Error reading file:', error); return; } console.log('File contents:', data); }); // Multiple operations function fetchUser(id, callback) { setTimeout(() => { const user = { id, name: 'John Doe' }; callback(null, user); }, 1000); } fetchUser(1, (error, user) => { if (error) { console.error('Error:', error); return; } console.log('User:', user); });

Callback Hell (Pyramid of Doom)

Nested callbacks can quickly become difficult to read and maintain:

// BAD: Callback hell example fs.readFile('user.json', 'utf8', (err, userData) => { if (err) return console.error(err); const user = JSON.parse(userData); fs.readFile(`posts/${user.id}.json`, 'utf8', (err, postsData) => { if (err) return console.error(err); const posts = JSON.parse(postsData); fs.readFile(`comments/${posts[0].id}.json`, 'utf8', (err, commentsData) => { if (err) return console.error(err); const comments = JSON.parse(commentsData); console.log(comments); // This keeps nesting deeper and deeper... }); }); });
Problem: Callback hell leads to code that's hard to read, debug, and maintain. Promises and async/await solve this problem.

Promises: A Better Approach

Promises represent the eventual completion (or failure) of an asynchronous operation and its resulting value.

Creating Promises

// Creating a Promise function fetchUser(id) { return new Promise((resolve, reject) => { setTimeout(() => { if (id < 1) { reject(new Error('Invalid user ID')); } else { resolve({ id, name: 'John Doe', email: 'john@example.com' }); } }, 1000); }); } // Using the Promise fetchUser(1) .then(user => { console.log('User:', user); return user.id; }) .then(userId => { console.log('User ID:', userId); }) .catch(error => { console.error('Error:', error.message); }) .finally(() => { console.log('Operation complete'); });

Promise States

A Promise has three states:

  • Pending: Initial state, neither fulfilled nor rejected
  • Fulfilled: Operation completed successfully (resolve called)
  • Rejected: Operation failed (reject called)

Chaining Promises

// Better than callback hell function readFilePromise(filename) { return new Promise((resolve, reject) => { fs.readFile(filename, 'utf8', (err, data) => { if (err) reject(err); else resolve(data); }); }); } // Chained promises readFilePromise('user.json') .then(userData => { const user = JSON.parse(userData); return readFilePromise(`posts/${user.id}.json`); }) .then(postsData => { const posts = JSON.parse(postsData); return readFilePromise(`comments/${posts[0].id}.json`); }) .then(commentsData => { const comments = JSON.parse(commentsData); console.log('Comments:', comments); }) .catch(error => { console.error('Error in chain:', error); });

Async/Await: The Modern Approach

Async/await is syntactic sugar built on top of Promises, making asynchronous code look and behave like synchronous code.

Basic Async/Await

// Async function always returns a Promise async function getUser(id) { // Simulating async operation const user = { id, name: 'John Doe' }; return user; // Automatically wrapped in Promise.resolve() } // Using await async function displayUser() { try { const user = await getUser(1); console.log('User:', user); } catch (error) { console.error('Error:', error); } } displayUser();

Error Handling with Try/Catch

const fs = require('fs').promises; // Promise-based fs API async function readAndProcessFile(filename) { try { // await pauses execution until Promise resolves const data = await fs.readFile(filename, 'utf8'); const parsed = JSON.parse(data); console.log('File contents:', parsed); return parsed; } catch (error) { // Catches both fs errors and JSON.parse errors console.error('Error:', error.message); throw error; // Re-throw if needed } finally { console.log('File operation complete'); } } // Call async function readAndProcessFile('data.json') .then(result => console.log('Success:', result)) .catch(error => console.error('Failed:', error));
Best Practice: Always use try/catch blocks with async/await to handle errors properly. Unhandled promise rejections can crash your application.

Sequential vs Parallel Execution

// SEQUENTIAL: Operations run one after another (slow) async function sequential() { console.time('Sequential'); const user = await fetchUser(1); // Wait 1 second const posts = await fetchPosts(1); // Wait 1 second const comments = await fetchComments(1); // Wait 1 second console.timeEnd('Sequential'); // ~3 seconds return { user, posts, comments }; } // PARALLEL: Operations run simultaneously (fast) async function parallel() { console.time('Parallel'); // Start all operations at once const [user, posts, comments] = await Promise.all([ fetchUser(1), fetchPosts(1), fetchComments(1) ]); console.timeEnd('Parallel'); // ~1 second (fastest operation) return { user, posts, comments }; }

Promise.all() - Wait for All

Execute multiple promises in parallel and wait for all to complete:

const promise1 = Promise.resolve(3); const promise2 = new Promise(resolve => setTimeout(() => resolve('foo'), 1000)); const promise3 = fetch('https://api.example.com/data'); // Wait for all promises Promise.all([promise1, promise2, promise3]) .then(results => { console.log('All results:', results); // [3, 'foo', Response object] }) .catch(error => { // If ANY promise rejects, catch is called console.error('One promise failed:', error); }); // Using with async/await async function fetchAllData() { try { const [users, posts, comments] = await Promise.all([ fetch('/api/users').then(r => r.json()), fetch('/api/posts').then(r => r.json()), fetch('/api/comments').then(r => r.json()) ]); return { users, posts, comments }; } catch (error) { console.error('Failed to fetch all data:', error); } }
Warning: Promise.all() fails fast. If any promise rejects, the entire operation fails immediately.

Promise.allSettled() - Wait Regardless

Wait for all promises to complete regardless of success or failure:

const promises = [ Promise.resolve('Success 1'), Promise.reject('Error 1'), Promise.resolve('Success 2'), Promise.reject('Error 2') ]; Promise.allSettled(promises) .then(results => { results.forEach((result, index) => { if (result.status === 'fulfilled') { console.log(`Promise ${index} succeeded:`, result.value); } else { console.log(`Promise ${index} failed:`, result.reason); } }); }); /* Output: Promise 0 succeeded: Success 1 Promise 1 failed: Error 1 Promise 2 succeeded: Success 2 Promise 3 failed: Error 2 */

Promise.race() - First to Complete

Returns the result of the first promise that completes (success or failure):

const slow = new Promise(resolve => setTimeout(() => resolve('slow'), 3000)); const fast = new Promise(resolve => setTimeout(() => resolve('fast'), 1000)); Promise.race([slow, fast]) .then(result => { console.log('Winner:', result); // 'fast' }); // Practical use: Timeout pattern async function fetchWithTimeout(url, timeout = 5000) { const fetchPromise = fetch(url); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Request timeout')), timeout); }); try { const response = await Promise.race([fetchPromise, timeoutPromise]); return response; } catch (error) { console.error('Fetch failed:', error.message); throw error; } }

Promise.any() - First Successful

Returns the first promise that successfully resolves:

const promises = [ Promise.reject('Error 1'), new Promise(resolve => setTimeout(() => resolve('Success 2'), 1000)), new Promise(resolve => setTimeout(() => resolve('Success 3'), 500)) ]; Promise.any(promises) .then(result => { console.log('First success:', result); // 'Success 3' }) .catch(error => { // Only called if ALL promises reject console.error('All failed:', error); });

Practice Exercise

  1. Create a function that returns a Promise which resolves after a delay
  2. Convert this function to use async/await
  3. Create three async functions that simulate API calls with different delays
  4. Use Promise.all() to run them in parallel and measure the time
  5. Implement error handling with try/catch
  6. Use Promise.race() to implement a timeout for a fetch request
  7. Create a function that retries a failed async operation 3 times before giving up

Summary

In this lesson, you learned:

  • How the event loop enables asynchronous operations in Node.js
  • Callbacks and the callback hell problem
  • Promises: creating, chaining, and error handling
  • Async/await syntax for cleaner asynchronous code
  • Error handling with try/catch blocks
  • Running parallel operations with Promise.all()
  • Different Promise combinators: allSettled(), race(), any()
  • Sequential vs parallel execution patterns