🧠Asynchronous Mastery

Level up async understanding—Promises, async/await, and beyond.

🔄 Callbacks vs Promises

Welcome to Asynchronous Mastery! Today we're diving into one of the most critical topics in Node.js development: understanding the difference between callbacks and promises. These concepts are essential for writing clean, efficient, and maintainable asynchronous code.

💡 What Are Callbacks?

A callback is a function passed to another function as an argument, which is then called after the completion of some operation. While they are simple and effective, improper use can lead to callback hell - a nightmare of nested callbacks that makes code hard to read and maintain.

💡 Callbacks vs Promises: Key Differences

  • Promises provide a cleaner syntax compared to nested callbacks.
  • Promises make error handling easier with .catch() method.
  • Promises support advanced operations like parallel execution (Promise.all()) and race conditions (Promise.race()).
  • Promises can be chained together using .then(), making asynchronous flows more readable.

Real-World Example: Callback Hell

fs.readFile('file1.txt', function(err, data) {
  if (err) throw err;
  fs.readFile('file2.txt', function(err, data2) {
    if (err) throw err;
    console.log(data + data2);
  });
});

Transforming Legacy Code with Promises

const fs = require('fs').promises;

fs.readFile('file1.txt')
  .then(data => fs.readFile('file2.txt'))
  .then(data2 => console.log(data + data2))
  .catch(err => console.error('Error:', err));

💡 Advanced Promise Techniques

  • Use Promise.all() when you need to wait for multiple operations to complete.
  • Use Promise.race() when you want the first operation to complete, ignoring others.
  • Chaining promises can help create a readable workflow for complex operations.

💡 Best Practices

  • Avoid using callbacks when working with async/await patterns.
  • Always handle errors with .catch() or try/catch blocks.
  • Refactor legacy code to use promises for better maintainability.

🚦 async/await Patterns

Welcome to the world of asynchronous programming with Node.js! In this chapter, we'll explore the async/await patterns that will help you write clean, efficient, and maintainable code. We'll cover everything from basic usage to advanced techniques like error handling, parallel execution, and cancellation patterns.

💡 What are async/await?

The async/await keywords are JavaScript's way of working with promises in a more readable and concise manner. They allow you to write asynchronous code that looks synchronous, making it easier to manage complex workflows.

async function fetchUser() {
  try {
    const response = await fetch('https://api.example.com/users');
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('Error:', error);
  }
}

Using try/catch with async/await

When working with async/await, always wrap your asynchronous operations in a try/catch block to handle potential errors. This ensures that your application doesn't crash and provides meaningful error messages to the user.

async function example() {
  try {
    // Asynchronous code here
    const result = await someAsyncFunction();
    console.log(result);
  } catch (error) {
    console.error('An error occurred:', error);
  }
}

Parallel Execution with Promise.all()

Use Promise.all() to execute multiple asynchronous operations in parallel. This is particularly useful when you need to make several independent API calls or perform multiple database queries at the same time.

async function fetchMultipleResources() {
  const [users, posts] = await Promise.all([
    fetch('https://api.example.com/users'),
    fetch('https://api.example.com/posts')
  ]);

  const userData = await users.json();
  const postData = await posts.json();

  return { userData, postData };
}

💡 Sequential Execution vs Parallel Execution

Understand the difference between sequential execution (running tasks one after another) and parallel execution (running tasks simultaneously). Use parallel execution when you want to save time by running operations concurrently, but be mindful of resource constraints.

  • Sequential: Use when the next task depends on the previous one completing.
  • Parallel: Use when tasks are independent and can be executed simultaneously.

Using Promise.race() for Competing Promises

The Promise.race() method takes an array of promises and returns a new promise that resolves or rejects as soon as one of the promises in the array does. This is useful for implementing timeouts or selecting the fastest response among multiple services.

async function fetchWithTimeout(url, timeout) {
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error('Request timed out'));
    }, timeout);
  });

  try {
    const response = await Promise.race([
      fetch(url),
      timeoutPromise
    ]);
    return await response.json();
  } catch (error) {
    console.error(error);
  }
}

💡 Cancellation Patterns with async/await

One of the challenges with async/await is handling cancellations. When you need to cancel an ongoing asynchronous operation, you can use techniques like abortable promises or external cancellation tokens.

const controller = new AbortController();

async function fetchWithCancel(url) {
  try {
    const response = await fetch(url, { signal: controller.signal });
    return await response.json();
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('Request was canceled');
    }
  }
}

// To cancel the request:
controller.abort();

Best Practices for Using async/await

  • Always use try/catch blocks to handle errors in asynchronous code.
  • Use Promise.all() for parallel execution and Promise.race() for competing promises.
  • Avoid nesting too many await calls (use flat async functions).
  • Consider using cancellation patterns for long-running operations.
  • Remember that top-level await is only available in module scope.

Common Mistakes to Avoid

  • Don't forget to add error handling with try/catch.
  • Don't mix callback-style code with async/await unnecessarily.
  • Avoid using synchronous operations inside async functions (use async equivalents instead).
  • Don't overuse Promise.all() - sometimes sequential execution is better.

💡 Summary of Key Concepts

In this chapter, we've explored the core concepts of async/await in Node.js. You should now be comfortable with using these patterns to handle asynchronous operations, including error handling, parallel execution, and cancellation strategies. Remember to always write clean, readable code that follows industry best practices.

🧯 Error Propagation in Async Code

When working with asynchronous JavaScript, handling errors properly is crucial for building robust applications. This section explores how to manage thrown, rejected, and uncaught errors using modern practices.

💡 Types of Errors in Async Code

  • Thrown errors: Occur when an error is explicitly thrown using throw.
  • Rejected promises: Happen when a promise is rejected with reject() or due to unhandled errors in the .then() chain.
  • Uncaught errors: Occur when an error isn't properly handled, leading to application crashes or unexpected behavior.

Handling Thrown Errors

Use try/catch blocks for synchronous operations and async/await with Promise.catch() for asynchronous operations.

async function processData() {
  try {
    const result = await fetch('data.json');
    return await result.json();
  } catch (error) {
    console.error('Error:', error);
    throw new Error('Failed to process data');
  }
}

Handling Rejected Promises

Use .catch() methods or combine with await/async for cleaner error handling.

function getData() {
  return fetch('data.json')
    .then(response => response.json())
    .catch(error => {
      console.error('Data fetching failed:', error);
      throw new Error('Data not available');
    });
}

Avoiding Uncaught Errors

Always provide error handlers for asynchronous operations. Never leave promises or async functions unhandled.

// Bad practice - no error handling
fetch('data.json').then(data => processData(data));

// Good practice with catch
fetch('data.json')
  .then(data => processData(data))
  .catch(error => console.error('Error:', error));

💡 Best Practices for Error Propagation

  • Always include a catch block in your promise chains.
  • Use async/await with try/catch for cleaner code.
  • Re-throw errors if you need to handle them at a higher level.
  • Log all errors before handling them for debugging purposes.

Real-world Application Example

Here's a complete example of error handling in a Node.js application using async/await and promises.

async function processPayment(cardData) {
  try {
    const paymentResult = await makePayment(cardData);
    if (paymentResult.status === 'success') {
      await sendConfirmationEmail(cardData.email);
      return { success: true, message: 'Payment processed successfully' };
    }
    throw new Error('Payment failed');
  } catch (error) {
    console.error('[Payment Error]', error);
    await notifySupport(error);
    throw new Error('Payment processing failed. Please try again.');
  }
}

processPayment({
  cardNumber: '1234-5678-9012-3456',
  email: 'user@example.com'
}).catch(error => {
  console.error('Payment failed:', error);
});

💡 Key Points to Remember

  • Always handle errors at the appropriate level in your application.
  • Never ignore errors; always log and provide feedback.
  • Use ⌘Error()° or custom error classes for clear error messages.

Quiz

Question 1 of 14

What is the primary advantage of using Promises over Callbacks?

  • Cleaner syntax and error handling
  • Faster execution speed
  • Support for synchronous operations
  • All of the above