DEV Community

Cover image for Mastering Asynchronous JavaScript: How to Effectively Use Promises, Async/Await, and Error Handling
mikias yonas
mikias yonas

Posted on

Mastering Asynchronous JavaScript: How to Effectively Use Promises, Async/Await, and Error Handling

Asynchronous JavaScript is crucial for modern web applications, but managing asynchronous tasks can be tricky. Understanding how to effectively use Promises, Async/Await, and common error-handling techniques will help you write cleaner, more efficient code. Below are some practical implementations and best practices that I’ve learned from real-world projects.

1. Promises vs. Async/Await: When to Use Which
Both Promises and Async/Await provide ways to handle asynchronous operations, but they shine in different contexts.

Promises are great when handling parallel operations like multiple API calls.

Async/Await is perfect when you need to write code that looks synchronous but handles asynchronous operations under the hood.

Example: Using Promises

const fetchData = (url) => {
  return fetch(url)
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error('Error:', error));
};
fetchData('https://api.example.com/data');
Enter fullscreen mode Exit fullscreen mode

Example: Using Async/Await

const fetchDataAsync = async (url) => {
  try {
    const response = await fetch(url);
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error('Error:', error);
  }
};

fetchData('https://api.example.com/data');
Enter fullscreen mode Exit fullscreen mode

Tip: Use Async/Await for readability and reducing callback hell, but for parallel tasks, Promise.all() is still your friend.

2. Handling Errors:
Proper Try-Catch and Promise Rejection's Errors are inevitable, but not handling them properly can lead to hidden bugs. For Async/Await, always use try-catch blocks to capture any errors. In Promises, always end with .catch() to handle rejections.

Example: Error Handling with Async/Await

const fetchWithErrorHandling = async () => {
  try {
    const res = await fetch('https://api.example.com/broken-url');
    const data = await res.json();
    return data;
  } catch (error) {
    console.error('Fetch failed:', error);
    throw new Error('Failed to fetch data'); // Propagate the error for further handling
  }
};
Enter fullscreen mode Exit fullscreen mode

Example: Error Handling with Promises

fetch('https://api.example.com/broken-url')
  .then(response => response.json())
  .catch(error => {
    console.error('Fetch failed:', error);
    // Handle the error here or propagate it
  });
Enter fullscreen mode Exit fullscreen mode

Tip: For complex applications, consider using a centralized error-handling service like Sentry to track errors in production.

3. Running Promises in Parallel with Promise.all()
When you need to run multiple asynchronous tasks concurrently, use Promise.all(). It waits for all promises to resolve or rejects as soon as one promise fails.

Example: Parallel Execution with Promise.all()

const fetchUser = fetch('https://api.example.com/user');
const fetchPosts = fetch('https://api.example.com/posts');

Promise.all([fetchUser, fetchPosts])
  .then(async ([userResponse, postsResponse]) => {
    const user = await userResponse.json();
    const posts = await postsResponse.json();
    console.log(user, posts);
  })
  .catch(error => console.error('One of the promises failed:', error));
Enter fullscreen mode Exit fullscreen mode

Tip: For tasks that don’t need to fail together, use Promise.allSettled() to continue processing other promises, even if some fail.

Example: Handling Multiple Task Failures with Promise.allSettled()

const fetchTasks = [fetch('https://api.example.com/task1'), fetch('https://api.example.com/task2')];

Promise.allSettled(fetchTasks).then(results => {
  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      console.log(`Task ${index + 1} fulfilled with data:`, result.value);
    } else {
      console.log(`Task ${index + 1} failed with reason:`, result.reason);
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

4. Avoid Async Pitfalls in Loops
Using forEach() with asynchronous operations can lead to unexpected results because forEach does not wait for async functions to resolve. Instead, use for...of or map() combined with Promise.all() to handle async operations in loops.

Example: Common Mistake with forEach and Async

const urls = ['url1', 'url2', 'url3'];
urls.forEach(async (url) => {
  const res = await fetch(url);
  const data = await res.json();
  console.log(data); // This might not work as expected
});
Enter fullscreen mode Exit fullscreen mode

Correct Example: Using for...of with Async/Await

const urls = ['url1', 'url2', 'url3'];

const fetchAllData = async () => {
  for (const url of urls) {
    const res = await fetch(url);
    const data = await res.json();
    console.log(data);
  }
};

fetchAllData();
Enter fullscreen mode Exit fullscreen mode

Tip: Use Promise.all() when you want to run all asynchronous operations concurrently within loops.

Example: Using Promise.all() with Map for Parallel Execution

const urls = ['url1', 'url2', 'url3'];

const fetchAllData = async () => {
  const promises = urls.map(url => fetch(url).then(res => res.json()));
  const results = await Promise.all(promises);
  results.forEach(data => console.log(data));
};

fetchAllData();
Enter fullscreen mode Exit fullscreen mode

5. Throttling and Debouncing for Optimized Performance
In real-time scenarios like handling input fields or scroll events, making too many API calls can slow down your app. Throttling limits how often a function is executed, while debouncing ensures that a function only executes after a certain delay.

Example: Debouncing Input with Lodash

const debouncedFetch = _.debounce(async (searchTerm) => {
  const res = await fetch(`https://api.example.com/search?query=${searchTerm}`);
  const data = await res.json();
  console.log(data);
}, 300);

// Attach to an input field's event listener
document.getElementById('search').addEventListener('input', (event) => {
  debouncedFetch(event.target.value);
});
Enter fullscreen mode Exit fullscreen mode

Tip: Use throttling for tasks like window resizing or scroll events where execution should happen at regular intervals, and debouncing for tasks like search inputs to prevent flooding the server with requests.

Conclusion
Mastering asynchronous JavaScript is critical for building responsive, high-performance applications. By understanding the nuances of Promises, Async/Await, and proper error handling, you can avoid common pitfalls and write efficient code. Whether you're running parallel tasks with Promise.all(), handling errors, or optimizing performance with debouncing, these tips will enhance your ability to manage asynchronous operations in any JavaScript project.

Top comments (0)