DEV Community

Nikolay Gushchin
Nikolay Gushchin

Posted on

Async Tricks in JavaScript for Smoother Code

Introduction

Ever found yourself staring at a loading screen, wondering if there's a better way to handle asynchronous operations in your JavaScript code? 😅 As web applications become more dynamic and data-intensive, mastering asynchronous programming isn't just a nice-to-have it's essential. In this article, we'll explore some async tricks in JavaScript that can help you write smoother, more efficient code.

Body

Understanding Asynchronous JavaScript

Before diving into the tricks, let's briefly recap why asynchronous programming is crucial in JavaScript. JavaScript is single-threaded, meaning it can execute one task at a time. Without async operations, tasks like fetching data from an API would block the main thread, leading to unresponsive applications.

1. Mastering Async/Await

Async/await, introduced in ES2017, has revolutionized how we handle asynchronous code, making it more readable and easier to manage.

Basic Example:

async function fetchData() {
  const response = await fetch('https://api.example.com/data');
  const data = await response.json();

  console.log(data);
}
Enter fullscreen mode Exit fullscreen mode

Tips:

  • Always Use Try/Catch Blocks: Wrap your await statements in try/catch blocks to handle errors gracefully.
async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    console.log(data);

  } catch (error) {
    console.error('Error fetching data:', error);
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Sequential vs Parallel Execution: Remember that using await in a loop will execute tasks sequentially. For parallel execution, store promises in an array and use Promise.all().

2. Leveraging Promise.all, Promise.race, and Promise.allSettled

Promise.all

Promise.all executes multiple promises in parallel and waits until all of them are resolved or any one is rejected.

Example: Fetch Multiple APIs in Parallel

async function getAllData() {
  try {
    const [users, posts, comments] = await Promise.all([
      fetch('/api/users').then(res => res.json()),
      fetch('/api/posts').then(res => res.json()),
      fetch('/api/comments').then(res => res.json())
    ]);

    console.log('Users:', users);
    console.log('Posts:', posts);
    console.log('Comments:', comments);
  } catch (error) {
    console.error('Error fetching data:', error);
  }
}
Enter fullscreen mode Exit fullscreen mode

Alternative Approach: sequential execution with Array.map

You can also achieve sequential execution with Array.map by using a function within the map callback and awaiting each operation.

async function fetchSequentially(urls) {
  const results = await Promise.all(
    urls.map(async (url, index) => {
      const response = await fetch(url);
      const data = await response.json();
      return data;
    })
  );

  return results;
}

// Usage
(async () => {
  try {
    const data = await fetchSequentially([
      '/api/data1',
      '/api/data2',
      '/api/data3'
    ]);

    console.log('All data:', data);
  } catch (error) {
    console.error('Error fetching data:', error);
  }
})();
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Efficiency: Reduces total loading time compared to sequential requests.
  • Error Handling: If any promise rejects, Promise.all rejects, allowing you to catch errors.

Using Promise.race

Promise.race returns a promise that resolves or rejects as soon as one of the promises resolves or rejects.

Example: Implementing a Timeout for a Fetch Request

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

  return Promise.race([fetchPromise, timeoutPromise]);
}

async function getData() {
  try {
    const response = await fetchWithTimeout('/api/data', 5000);
    const data = await response.json();
    console.log('Data:', data);
  } catch (error) {
    console.error(error.message);
  }
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Timeouts: Useful for setting time limits on promises.
  • First Resolved/Rejection: Acts upon the first completed promise.

Using Promise.allSettled

Promise.allSettled waits for all promises to settle (either fulfilled or rejected) and returns an array of their results.

Example: Handling Multiple Promises Regardless of Outcome

async function getAllStatuses() {
  const urls = ['/api/users', '/api/posts', '/api/invalidEndpoint'];

  const results = await Promise.allSettled(
    urls.map(url => fetch(url).then(res => res.json()))
  );

  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      console.log(`Response from ${urls[index]}:`, result.value);
    } else {
      console.error(`Error fetching ${urls[index]}:`, result.reason);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Comprehensive Results: Get the outcome of all promises, regardless of whether they fulfilled or rejected.
  • Error Isolation: One promise's rejection doesn't affect the others.

3. Async Iteration with for await...of

When dealing with streams of data or asynchronous iterables, for await...of can be incredibly useful.

Example:

async function processStream(stream) {
  for await (const chunk of stream) {
    console.log('Received chunk:', chunk);
  }
}
Enter fullscreen mode Exit fullscreen mode

Use Cases:

  • Reading large files.
  • Processing real-time data streams.

4. Handling Errors Effectively

Proper error handling in async code prevents crashes and improves user experience.

Global Error Handling:

window.addEventListener('unhandledrejection', event => {
  console.error('Unhandled promise rejection:', event.reason);
});
Enter fullscreen mode Exit fullscreen mode

Best Practices:

  • Catch Rejections: Always catch promise rejections.
  • Provide Feedback: Inform users when an error occurs.

5. Canceling Async Operations

Although JavaScript doesn't support canceling promises out of the box, you can implement cancellation tokens.

Using AbortController:

const controller = new AbortController();
const signal = controller.signal;

fetch('/api/data', { signal })
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('Fetch aborted');
    } else {
      console.error('Fetch error:', err);
    }
  });

// Cancel the request
controller.abort();
Enter fullscreen mode Exit fullscreen mode

Advantages:

  • Resource Management: Free up resources by canceling unnecessary requests.
  • User Experience: Improve responsiveness by aborting outdated operations.

6. Utilizing Async Generators

Async generators combine the power of generators and async functions, allowing you to work with asynchronous data streams.

Example:

async function* fetchPages(urls) {
  for (const url of urls) {
    const response = await fetch(url);
    const data = await response.json();
    yield data;
  }
}

(async () => {
  const urls = ['/api/page1', '/api/page2', '/api/page3'];
  for await (const pageData of fetchPages(urls)) {
    console.log('Page data:', pageData);
  }
})();
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Memory Efficiency: Process data chunks without loading everything into memory.
  • Control Flow: Handle asynchronous sequences elegantly.

Conclusion

Mastering asynchronous programming in JavaScript is key to building responsive and efficient applications. By leveraging async/await, promises, and other async patterns, you can write smoother code that not only performs better but is also easier to read and maintain. So go ahead, incorporate these async tricks into your projects, and experience the difference they make. Happy coding! 🚀

Top comments (0)