A guide to async/await in JavaScript with examples

Cover Image for A guide to async/await in JavaScript with examples
toofancoder
toofancoder

Mastering async/await in JavaScript: A Practical Guide

Asynchronous programming is essential for modern JavaScript development. The async/await syntax introduced in ES2017 has revolutionized how we handle asynchronous operations, making the code more readable and easier to maintain. In this post, we'll explore how to use async/await effectively through various scenarios with code examples.

Understanding async and await

async functions enable you to write asynchronous code that looks and behaves like synchronous code. The await keyword pauses the execution of an async function until a promise is resolved, making it easier to work with promises.

Basic Example

Here's a simple example of how to use async and await:

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

fetchData();

In this example, fetchData is an async function that fetches data from an API. The await keyword pauses the function until the fetch promise resolves.

Error Handling

Handling errors in async functions is straightforward with try/catch:

async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error('Fetch error:', error);
  }
}

fetchData();

In this example, if the fetch operation fails or the response is not OK, the error is caught and logged.

Multiple await Statements

You can wait for multiple promises using await:

async function fetchMultipleData() {
  const [response1, response2] = await Promise.all([
    fetch('https://api.example.com/data1'),
    fetch('https://api.example.com/data2')
  ]);

  const data1 = await response1.json();
  const data2 = await response2.json();

  console.log(data1, data2);
}

fetchMultipleData();

Here, Promise.all runs multiple promises in parallel, and await waits for all of them to resolve.

Sequential await Statements

For sequential operations, use multiple await statements one after another:

async function fetchSequentialData() {
  const response1 = await fetch('https://api.example.com/data1');
  const data1 = await response1.json();

  const response2 = await fetch('https://api.example.com/data2');
  const data2 = await response2.json();

  console.log(data1, data2);
}

fetchSequentialData();

This approach ensures that the second fetch operation does not start until the first one completes.

Using async/await in Loops

You can use async/await in loops, but be mindful of performance implications:

async function fetchInLoop(urls) {
  for (const url of urls) {
    const response = await fetch(url);
    const data = await response.json();
    console.log(data);
  }
}

const urls = ['https://api.example.com/data1', 'https://api.example.com/data2'];
fetchInLoop(urls);

This fetches data sequentially. For parallel fetching, use Promise.all:

async function fetchInParallel(urls) {
  const promises = urls.map(url => fetch(url));
  const responses = await Promise.all(promises);
  const dataPromises = responses.map(response => response.json());
  const data = await Promise.all(dataPromises);
  console.log(data);
}

fetchInParallel(urls);

Real-World Examples

1. Retrying Failed Requests

Sometimes API calls fail and need to be retried. Here's a practical implementation of a retry mechanism:

async function fetchWithRetry(url, retries = 3, delay = 1000) {
  for (let i = 0; i < retries; i++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      return await response.json();
    } catch (error) {
      if (i === retries - 1) throw error; // Last attempt failed
      console.log(`Attempt ${i + 1} failed, retrying in ${delay}ms...`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Usage
fetchWithRetry('https://api.example.com/data')
  .then(data => console.log('Success:', data))
  .catch(error => console.error('Final error:', error));

This function attempts to fetch data up to three times, with a delay between attempts. It's useful for handling temporary network issues or rate limiting.

2. Loading Data with Progress Updates

Here's how to handle multiple async operations while showing progress:

async function loadDataWithProgress(items) {
  const total = items.length;
  const results = [];
  
  for (let i = 0; i < total; i++) {
    try {
      const result = await fetch(items[i]);
      const data = await result.json();
      results.push(data);
      
      // Calculate and report progress
      const progress = ((i + 1) / total * 100).toFixed(1);
      console.log(`Progress: ${progress}%`);
    } catch (error) {
      console.error(`Failed to load item ${i + 1}:`, error);
    }
  }
  
  return results;
}

// Usage
const urls = [
  'https://api.example.com/user/1',
  'https://api.example.com/user/2',
  'https://api.example.com/user/3'
];
loadDataWithProgress(urls);

This example shows how to track progress when loading multiple items sequentially.

3. Implementing a Rate Limiter

Here's a practical example of rate limiting API calls:

class RateLimiter {
  constructor(maxRequests, timeWindow) {
    this.queue = [];
    this.maxRequests = maxRequests;
    this.timeWindow = timeWindow;
  }

  async execute(fn) {
    // Remove old timestamps
    const now = Date.now();
    this.queue = this.queue.filter(timestamp => 
      now - timestamp < this.timeWindow
    );

    // Wait if queue is full
    if (this.queue.length >= this.maxRequests) {
      const oldestTimestamp = this.queue[0];
      const waitTime = oldestTimestamp + this.timeWindow - now;
      await new Promise(resolve => setTimeout(resolve, waitTime));
    }

    // Add new timestamp and execute function
    this.queue.push(now);
    return fn();
  }
}

// Usage example
const limiter = new RateLimiter(2, 1000); // 2 requests per second

async function fetchWithRateLimit(urls) {
  const results = [];
  
  for (const url of urls) {
    const result = await limiter.execute(async () => {
      const response = await fetch(url);
      return response.json();
    });
    results.push(result);
  }
  
  return results;
}

This implementation ensures that API calls are spaced out to prevent overwhelming the server or hitting rate limits.

4. Async Event Handler with Debouncing

Here's how to implement a debounced async search function:

function debounce(func, wait) {
  let timeout;
  return function executedFunction(...args) {
    return new Promise(resolve => {
      const later = async () => {
        clearTimeout(timeout);
        resolve(await func(...args));
      };
      clearTimeout(timeout);
      timeout = setTimeout(later, wait);
    });
  };
}

const searchAPI = async (query) => {
  const response = await fetch(`https://api.example.com/search?q=${query}`);
  return response.json();
};

const debouncedSearch = debounce(searchAPI, 300);

// Usage in an event listener
async function handleSearch(event) {
  try {
    const results = await debouncedSearch(event.target.value);
    console.log('Search results:', results);
  } catch (error) {
    console.error('Search failed:', error);
  }
}

This example shows how to combine async/await with debouncing, which is useful for search inputs or other frequent user interactions.

Conclusion

async/await makes asynchronous JavaScript code cleaner and more readable. Whether you're dealing with API calls, handling multiple operations, or managing errors, this syntax can significantly simplify your code.

Happy coding!