Introduction

Asynchronous programming is essential for modern JavaScript development. It allows your code to perform time-consuming operations like fetching data from servers, reading files, or waiting for user input without freezing the entire application. In this lesson, you'll master Promises, async/await, and learn how to handle asynchronous operations elegantly.

Learning Objectives

By the end of this lesson, you will be able to:

  • Understand synchronous vs asynchronous code execution
  • Work with callbacks and avoid callback hell
  • Master Promises and promise chaining
  • Use async/await for cleaner asynchronous code
  • Handle errors in asynchronous operations
  • Understand the event loop and task queue

Prerequisites

  • Completion of Lessons 01-03
  • Strong understanding of functions and callbacks
  • Familiarity with array methods

Estimated Time

4 hours (including practice exercises)

Synchronous vs Asynchronous

Understanding the difference between synchronous and asynchronous code is fundamental:

Synchronous Code

JavaScript
// Synchronous - executes line by line
console.log('First');
console.log('Second');
console.log('Third');

// Output:
// First
// Second
// Third

Asynchronous Code

JavaScript
// Asynchronous - doesn't block execution
console.log('First');

setTimeout(() => {
  console.log('Second (after 1 second)');
}, 1000);

console.log('Third');

// Output:
// First
// Third
// Second (after 1 second)
Key Concept: Asynchronous operations don't block the execution of subsequent code. JavaScript continues running while waiting for async operations to complete.

Callbacks

Callbacks are functions passed as arguments to be executed later:

JavaScript
// Simple callback example
function fetchUser(id, callback) {
  setTimeout(() => {
    const user = { id: id, name: 'Alice' };
    callback(user);
  }, 1000);
}

fetchUser(1, (user) => {
  console.log('User:', user);
});

// Callback with error handling
function fetchData(url, onSuccess, onError) {
  setTimeout(() => {
    const success = Math.random() > 0.5;
    if (success) {
      onSuccess({ data: 'Some data' });
    } else {
      onError(new Error('Failed to fetch'));
    }
  }, 1000);
}

fetchData(
  '/api/data',
  (data) => console.log('Success:', data),
  (error) => console.error('Error:', error)
);

Callback Hell

Nested callbacks can become difficult to read and maintain:

JavaScript
// Callback hell - hard to read and maintain
fetchUser(1, (user) => {
  fetchPosts(user.id, (posts) => {
    fetchComments(posts[0].id, (comments) => {
      fetchLikes(comments[0].id, (likes) => {
        console.log('Finally got likes:', likes);
      });
    });
  });
});
Problem: Deeply nested callbacks create "pyramid of doom" that's hard to read, debug, and maintain. Promises solve this problem.

Promises

A Promise represents a value that may be available now, in the future, or never. It has three states: pending, fulfilled, or rejected.

Creating Promises

JavaScript
// Basic Promise creation
const myPromise = new Promise((resolve, reject) => {
  // Simulate async operation
  setTimeout(() => {
    const success = true;
    
    if (success) {
      resolve('Operation successful!');
    } else {
      reject('Operation failed!');
    }
  }, 1000);
});

// Using the Promise
myPromise
  .then(result => {
    console.log(result); // "Operation successful!"
  })
  .catch(error => {
    console.error(error);
  });

// Practical example: Fetch user data
function fetchUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id > 0) {
        resolve({ id: id, name: 'Alice', email: '[email protected]' });
      } else {
        reject(new Error('Invalid user ID'));
      }
    }, 1000);
  });
}

fetchUser(1)
  .then(user => console.log('User:', user))
  .catch(error => console.error('Error:', error));

Promise Chaining

JavaScript
// Chain multiple async operations
function fetchUser(id) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({ id: id, name: 'Alice' });
    }, 500);
  });
}

function fetchPosts(userId) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([
        { id: 1, title: 'Post 1', userId: userId },
        { id: 2, title: 'Post 2', userId: userId }
      ]);
    }, 500);
  });
}

function fetchComments(postId) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([
        { id: 1, text: 'Great post!', postId: postId },
        { id: 2, text: 'Thanks for sharing', postId: postId }
      ]);
    }, 500);
  });
}

// Clean promise chain (no callback hell!)
fetchUser(1)
  .then(user => {
    console.log('User:', user);
    return fetchPosts(user.id);
  })
  .then(posts => {
    console.log('Posts:', posts);
    return fetchComments(posts[0].id);
  })
  .then(comments => {
    console.log('Comments:', comments);
  })
  .catch(error => {
    console.error('Error:', error);
  });

Async/Await

Async/await is syntactic sugar over Promises, making asynchronous code look and behave more like synchronous code:

Basic Async/Await

JavaScript
// Using async/await
async function getUserData() {
  try {
    const user = await fetchUser(1);
    console.log('User:', user);
    
    const posts = await fetchPosts(user.id);
    console.log('Posts:', posts);
    
    const comments = await fetchComments(posts[0].id);
    console.log('Comments:', comments);
    
    return comments;
  } catch (error) {
    console.error('Error:', error);
  }
}

// Call async function
getUserData();

// Async function always returns a Promise
getUserData().then(comments => {
  console.log('All done!', comments);
});

Error Handling with Async/Await

JavaScript
// Proper error handling
async function fetchDataSafely() {
  try {
    const response = await fetch('/api/data');
    
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('Failed to fetch data:', error);
    throw error; // Re-throw if needed
  }
}

// Multiple try-catch blocks
async function processData() {
  let user;
  
  try {
    user = await fetchUser(1);
  } catch (error) {
    console.error('Failed to fetch user:', error);
    return; // Exit early
  }
  
  try {
    const posts = await fetchPosts(user.id);
    console.log('Posts:', posts);
  } catch (error) {
    console.error('Failed to fetch posts:', error);
    // Continue execution
  }
  
  console.log('Function completed');
}

Promise Methods

Promise.all()

Run multiple promises concurrently and wait for all to complete:

JavaScript
// Run promises in parallel
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())
    ]);
    
    console.log('Users:', users);
    console.log('Posts:', posts);
    console.log('Comments:', comments);
  } catch (error) {
    console.error('One of the requests failed:', error);
  }
}

// If any promise rejects, Promise.all() rejects immediately
Promise.all([
  Promise.resolve(1),
  Promise.reject('Error!'),
  Promise.resolve(3)
])
  .then(results => console.log(results))
  .catch(error => console.error(error)); // "Error!"

Promise.race()

Returns the first promise that settles (resolves or rejects):

JavaScript
// Timeout pattern with Promise.race()
function timeout(ms) {
  return new Promise((_, reject) => {
    setTimeout(() => reject(new Error('Timeout')), ms);
  });
}

async function fetchWithTimeout(url, ms) {
  try {
    const response = await Promise.race([
      fetch(url),
      timeout(ms)
    ]);
    return await response.json();
  } catch (error) {
    console.error('Request failed or timed out:', error);
  }
}

// Use it
fetchWithTimeout('/api/data', 5000); // 5 second timeout

Promise.allSettled()

Wait for all promises to settle, regardless of success or failure:

JavaScript
// Get results from all promises, even if some fail
async function fetchMultipleResources() {
  const results = await Promise.allSettled([
    fetch('/api/users').then(r => r.json()),
    fetch('/api/posts').then(r => r.json()),
    fetch('/api/invalid').then(r => r.json()) // This will fail
  ]);
  
  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      console.log(`Request ${index} succeeded:`, result.value);
    } else {
      console.log(`Request ${index} failed:`, result.reason);
    }
  });
}

// Output:
// Request 0 succeeded: [users data]
// Request 1 succeeded: [posts data]
// Request 2 failed: Error: 404 Not Found

Promise.any()

Returns the first promise that fulfills:

JavaScript
// Try multiple servers, use first successful response
async function fetchFromMultipleServers() {
  try {
    const data = await Promise.any([
      fetch('https://api1.example.com/data'),
      fetch('https://api2.example.com/data'),
      fetch('https://api3.example.com/data')
    ]);
    
    return await data.json();
  } catch (error) {
    console.error('All requests failed:', error);
  }
}

The Event Loop

Understanding how JavaScript handles asynchronous operations:

JavaScript
// Event loop demonstration
console.log('1: Synchronous');

setTimeout(() => {
  console.log('2: Timeout (macrotask)');
}, 0);

Promise.resolve().then(() => {
  console.log('3: Promise (microtask)');
});

console.log('4: Synchronous');

// Output order:
// 1: Synchronous
// 4: Synchronous
// 3: Promise (microtask)
// 2: Timeout (macrotask)

// Microtasks (Promises) execute before macrotasks (setTimeout)
Event Loop Order: 1. Execute synchronous code
2. Execute all microtasks (Promises)
3. Execute one macrotask (setTimeout, setInterval)
4. Repeat

Practical Example: Data Fetching Service

JavaScript
// Complete data fetching service with caching
class DataService {
  constructor() {
    this.cache = new Map();
  }
  
  async fetchWithCache(url, ttl = 60000) {
    // Check cache
    const cached = this.cache.get(url);
    if (cached && Date.now() - cached.timestamp < ttl) {
      console.log('Returning cached data');
      return cached.data;
    }
    
    // Fetch fresh data
    try {
      console.log('Fetching fresh data');
      const response = await fetch(url);
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      
      const data = await response.json();
      
      // Update cache
      this.cache.set(url, {
        data: data,
        timestamp: Date.now()
      });
      
      return data;
    } catch (error) {
      console.error('Fetch failed:', error);
      
      // Return stale cache if available
      if (cached) {
        console.log('Returning stale cache');
        return cached.data;
      }
      
      throw error;
    }
  }
  
  async fetchMultiple(urls) {
    const promises = urls.map(url => this.fetchWithCache(url));
    return await Promise.allSettled(promises);
  }
  
  clearCache() {
    this.cache.clear();
  }
}

// Usage
const service = new DataService();

async function loadDashboard() {
  try {
    const [users, posts, stats] = await Promise.all([
      service.fetchWithCache('/api/users'),
      service.fetchWithCache('/api/posts'),
      service.fetchWithCache('/api/stats')
    ]);
    
    console.log('Dashboard loaded:', { users, posts, stats });
  } catch (error) {
    console.error('Failed to load dashboard:', error);
  }
}

loadDashboard();

Key Takeaways

  • Asynchronous code doesn't block execution
  • Promises represent eventual completion of async operations
  • Async/await provides cleaner syntax than promise chains
  • Always use try/catch with async/await for error handling
  • Promise.all() runs multiple promises concurrently
  • Promise.allSettled() waits for all promises regardless of outcome
  • Microtasks (Promises) execute before macrotasks (setTimeout)

Practice Exercises

Exercise 1: Promise Creation

Create a function that simulates a dice roll with a delay:

Task
// Create a function rollDice() that:
// 1. Returns a Promise
// 2. Waits 1 second
// 3. Resolves with a random number 1-6
// 4. Rejects if the number is 1 (unlucky!)

function rollDice() {
  // Your code here
}

// Test it
rollDice()
  .then(result => console.log('Rolled:', result))
  .catch(error => console.log('Unlucky!', error));

Exercise 2: Async/Await Conversion

Convert this promise chain to async/await:

Task
// Convert to async/await
function getFullUserData(userId) {
  return fetchUser(userId)
    .then(user => {
      return fetchPosts(user.id)
        .then(posts => {
          return { user, posts };
        });
    })
    .catch(error => {
      console.error('Error:', error);
    });
}

// Your async/await version:
async function getFullUserData(userId) {
  // Your code here
}

Exercise 3: Parallel Requests

Fetch data from multiple APIs concurrently:

Task
// Fetch user, posts, and comments in parallel
// Calculate total time taken
// Handle errors gracefully

async function fetchAllUserData(userId) {
  // Your code here
  // Hint: Use Promise.all() and console.time()
}

Exercise 4: Retry Logic

Implement a function that retries failed requests:

Task
// Create a retry function that:
// 1. Tries to execute an async function
// 2. Retries up to maxRetries times if it fails
// 3. Waits delay ms between retries
// 4. Throws error if all retries fail

async function retry(fn, maxRetries = 3, delay = 1000) {
  // Your code here
}

// Test it
retry(() => fetch('/api/unreliable'), 3, 1000)
  .then(response => console.log('Success!'))
  .catch(error => console.log('All retries failed'));

Common Pitfalls

1. Forgetting to await

JavaScript
// Wrong - missing await
async function getData() {
  const data = fetchUser(1); // Returns Promise, not data!
  console.log(data.name); // undefined
}

// Correct
async function getData() {
  const data = await fetchUser(1);
  console.log(data.name); // Works!
}

2. Not handling errors

JavaScript
// Wrong - unhandled promise rejection
async function riskyOperation() {
  const data = await fetch('/api/data'); // Might fail!
  return data.json();
}

// Correct
async function riskyOperation() {
  try {
    const data = await fetch('/api/data');
    return await data.json();
  } catch (error) {
    console.error('Operation failed:', error);
    throw error;
  }
}

3. Sequential instead of parallel

JavaScript
// Slow - sequential (4 seconds total)
async function fetchSequential() {
  const users = await fetchUsers(); // 2 seconds
  const posts = await fetchPosts(); // 2 seconds
  return { users, posts };
}

// Fast - parallel (2 seconds total)
async function fetchParallel() {
  const [users, posts] = await Promise.all([
    fetchUsers(), // 2 seconds
    fetchPosts()  // 2 seconds (runs simultaneously)
  ]);
  return { users, posts };
}

What's Next?

In Lesson 05, you'll learn how to work with external APIs using the Fetch API. You'll make HTTP requests, handle JSON data, work with query parameters and headers, and build complete API integrations for real-world applications.