Asynchronous JavaScript: Callbacks, Promises, and Async/Await

Asynchronous programming is a cornerstone of JavaScript, enabling non-blocking operations and efficient handling of tasks like network requests, file I/O, and timers. Over time, JavaScript has evolved from using callbacks to promises and finally to the modern async/await syntax. This lesson delves into the different patterns for handling asynchronous code, the issues associated with each, and best practices for writing clean, efficient asynchronous JavaScript.

Callbacks in JavaScript

What is a Callback Function?

  • Definition: A callback is a function passed as an argument to another function, to be executed after an operation has completed.

  • Purpose: Allows asynchronous functions to notify when an operation is finished.

Example:

function fetchData(callback) {
  setTimeout(() => {
    const data = { name: 'Alice' }
    callback(data)
  }, 1000)
}

fetchData((result) => {
  console.log('Data:', result)
})

Explanation:

  • fetchData simulates an asynchronous operation using setTimeout.
  • It accepts a callback function, which it calls after the data is "fetched".
  • When fetchData is called, we pass an anonymous function that logs the result.

Callback Patterns

1.2.1 Error-First Callbacks

  • Pattern: The first argument of the callback is an error object (if any), followed by the result.

Example:

function readFile(callback) {
  setTimeout(() => {
    const error = null // or new Error('File not found');
    const data = 'File content'
    callback(error, data)
  }, 1000)
}

readFile((err, data) => {
  if (err) {
    console.error('Error:', err)
  } else {
    console.log('Data:', data)
  }
})

Explanation:

  • This pattern standardizes error handling in asynchronous operations.

1.2.2 Callback Nesting

  • Issue: Multiple asynchronous operations lead to nested callbacks, known as "callback hell".

Example:

doFirstTask((err, result1) => {
  if (err) return handleError(err)
  doSecondTask(result1, (err, result2) => {
    if (err) return handleError(err)
    doThirdTask(result2, (err, result3) => {
      if (err) return handleError(err)
      // Continue...
    })
  })
})

Visualization:

doFirstTask
  └── doSecondTask
        └── doThirdTask

Issues with Callbacks

1.3.1 Callback Hell

  • Definition: Difficulty in reading and maintaining deeply nested callback code.

  • Problems:

    • Hard to read and understand.
    • Difficult to handle errors.
    • Challenging to add error handling or new features.

1.3.2 Inversion of Control

  • Definition: Passing control of the program flow to a third-party function.

  • Issue: Loss of control over when and how the callback is called.

1.3.3 Error Handling Complexity

  • Issue: Errors need to be handled at every level of the callback chain.

Promises

What is a Promise?

  • Definition: An object representing the eventual completion or failure of an asynchronous operation.

  • States:

    • Pending: Initial state, neither fulfilled nor rejected.
    • Fulfilled: Operation completed successfully.
    • Rejected: Operation failed.

Example:

const promise = new Promise((resolve, reject) => {
  // Asynchronous operation
  setTimeout(() => {
    const success = true
    if (success) {
      resolve('Data retrieved')
    } else {
      reject(new Error('Failed to retrieve data'))
    }
  }, 1000)
})

promise
  .then((result) => {
    console.log('Success:', result)
  })
  .catch((error) => {
    console.error('Error:', error)
  })

Explanation:

  • Promise constructor takes a function with resolve and reject parameters.
  • Use resolve(value) to fulfill the promise.
  • Use reject(error) to reject the promise.
  • then() handles fulfilled state.
  • catch() handles rejected state.

Promise Chaining

  • Purpose: Allows sequential execution of asynchronous operations.

Example:

doFirstTask()
  .then((result1) => {
    return doSecondTask(result1)
  })
  .then((result2) => {
    return doThirdTask(result2)
  })
  .then((result3) => {
    console.log('All tasks completed:', result3)
  })
  .catch((error) => {
    console.error('Error:', error)
  })

Explanation:

  • Each then() returns a new promise, allowing chaining.
  • Errors are propagated down the chain to the catch() block.

Error Handling in Promises

  • Automatic Propagation: If a promise is rejected, it skips subsequent then() handlers until it finds a catch().

Example:

doFirstTask()
  .then((result1) => {
    return doSecondTask(result1)
  })
  .then((result2) => {
    // Simulate an error
    throw new Error('Something went wrong')
  })
  .then((result3) => {
    // This will be skipped
    return doThirdTask(result3)
  })
  .catch((error) => {
    console.error('Caught an error:', error)
  })

Explanation:

  • Throwing an error inside then() or rejecting a promise calls the catch() block.

Creating Promises

2.4.1 Wrapping Callback APIs

  • Purpose: Convert callback-based functions to return promises.

Example:

function readFilePromise() {
  return new Promise((resolve, reject) => {
    readFile((err, data) => {
      if (err) reject(err)
      else resolve(data)
    })
  })
}

2.4.2 Using Promise.resolve() and Promise.reject()

  • Purpose: Create a promise that is already fulfilled or rejected.

Example:

Promise.resolve('Immediate value').then((value) => {
  console.log(value) // Outputs: Immediate value
})

Promise.reject(new Error('Immediate rejection')).catch((error) => {
  console.error(error)
})

Combining Promises

2.5.1 Promise.all()

  • Purpose: Wait for multiple promises to fulfill; rejects if any promise rejects.

Example:

Promise.all([promise1, promise2, promise3])
  .then((results) => {
    console.log('All fulfilled:', results)
  })
  .catch((error) => {
    console.error('One or more promises rejected:', error)
  })

2.5.2 Promise.race()

  • Purpose: Resolves or rejects as soon as one promise resolves or rejects.

Example:

Promise.race([promise1, promise2, promise3])
  .then((result) => {
    console.log('First resolved:', result)
  })
  .catch((error) => {
    console.error('First rejected:', error)
  })

2.5.3 Promise.allSettled()

  • Purpose: Waits for all promises to settle (either fulfilled or rejected).

Example:

Promise.allSettled([promise1, promise2, promise3]).then((results) => {
  results.forEach((result) => {
    if (result.status === 'fulfilled') {
      console.log('Fulfilled:', result.value)
    } else {
      console.log('Rejected:', result.reason)
    }
  })
})

Async/Await

What is Async/Await?

  • Definition: Syntactic sugar over promises, making asynchronous code look and behave like synchronous code.

  • Keywords:

    • async: Declares an async function that returns a promise.
    • await: Pauses the execution of an async function until the awaited promise is settled.

Using Async/Await

Example:

async function fetchData() {
  try {
    const response = await fetch('/api/data')
    const data = await response.json()
    console.log('Data:', data)
  } catch (error) {
    console.error('Error:', error)
  }
}

fetchData()

Explanation:

  • fetchData is an async function.
  • await pauses execution until the promise is resolved.
  • try...catch handles errors, similar to synchronous code.

Error Handling with Async/Await

  • Using try...catch: Wrap await expressions in try...catch blocks to handle errors.

Example:

async function processData() {
  try {
    const data = await getData()
    const processed = await process(data)
    console.log('Processed Data:', processed)
  } catch (error) {
    console.error('Error:', error)
  }
}

Parallel Execution with Async/Await

  • Issue: await statements are executed sequentially by default.

  • Solution: Start promises without awaiting, then await them later.

Example:

async function getData() {
  const promise1 = fetch('/api/data1')
  const promise2 = fetch('/api/data2')

  const response1 = await promise1
  const response2 = await promise2

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

  return [data1, data2]
}

Explanation:

  • Both fetch requests start simultaneously.
  • Awaiting their results after both requests have been initiated.

Combining Async/Await with Promise Methods

  • Example:
async function getAllData() {
  try {
    const [data1, data2] = await Promise.all([getData1(), getData2()])
    console.log('Data:', data1, data2)
  } catch (error) {
    console.error('Error:', error)
  }
}

Best Practices

Use Async/Await Over Callbacks and Promises

  • Reason: Improves code readability and maintainability.

  • Example:

    Before (Promises):

    function getData() {
      return fetch('/api/data')
        .then((response) => response.json())
        .then((data) => process(data))
        .catch((error) => handleError(error))
    }
    

    After (Async/Await):

    async function getData() {
      try {
        const response = await fetch('/api/data')
        const data = await response.json()
        return process(data)
      } catch (error) {
        handleError(error)
      }
    }
    

Handle Errors Properly

  • Reason: Prevents unhandled rejections and improves reliability.

  • Solution:

    • Use try...catch with async/await.
    • Use .catch() with promises.

Avoid Blocking the Event Loop

  • Issue: Synchronous code or long-running loops block the event loop.

  • Solution: Use asynchronous APIs or offload heavy computations.

Be Careful with Promise Rejections

  • Issue: Unhandled promise rejections can cause crashes.

  • Solution:

    • Always handle rejections with .catch() or try...catch.
    • Use process.on('unhandledRejection', handler) in Node.js.

Limit Concurrent Operations

  • Issue: Too many concurrent promises can overwhelm resources.

  • Solution:

    • Implement concurrency control.
    • Use libraries like p-limit for limiting concurrent promises.

Avoid Mixing Callback and Promise APIs

  • Reason: Mixing patterns can lead to confusion and errors.

  • Solution:

    • Prefer promises and async/await over callbacks.
    • If using callbacks, consider converting them to promises.

Use Linter Rules

  • Recommendation: Use ESLint with plugins like eslint-plugin-promise to enforce best practices.

Exercises

Exercise 1: Converting Callbacks to Promises

Question:

Given the following callback-based function, rewrite it to return a promise.

function getUserData(callback) {
  setTimeout(() => {
    const data = { id: 1, name: 'Alice' }
    callback(null, data)
  }, 1000)
}

getUserData((err, data) => {
  if (err) {
    console.error('Error:', err)
  } else {
    console.log('User Data:', data)
  }
})

Answer:

function getUserData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const data = { id: 1, name: 'Alice' }
      resolve(data)
    }, 1000)
  })
}

getUserData()
  .then((data) => {
    console.log('User Data:', data)
  })
  .catch((err) => {
    console.error('Error:', err)
  })

Explanation:

  • Removed the callback parameter.
  • Returned a new promise.
  • Used resolve to fulfill the promise with data.
  • Handled the promise with .then() and .catch().

Exercise 2: Promise Chaining

Question:

Using promises, perform the following tasks sequentially:

  1. Fetch user data (fetchUserData).
  2. Fetch user's posts using the user ID (fetchUserPosts).
  3. Log the posts.

Assume both fetchUserData and fetchUserPosts return promises.

Answer:

fetchUserData()
  .then((user) => {
    return fetchUserPosts(user.id)
  })
  .then((posts) => {
    console.log('User Posts:', posts)
  })
  .catch((error) => {
    console.error('Error:', error)
  })

Explanation:

  • Chained promises to execute tasks sequentially.
  • Passed user ID from the first promise to the second.
  • Logged the posts.

Exercise 3: Error Handling with Async/Await

Question:

Rewrite the following promise-based code using async/await and handle errors using try...catch.

function fetchData() {
  return fetch('/api/data')
    .then((response) => response.json())
    .then((data) => {
      console.log('Data:', data)
    })
    .catch((error) => {
      console.error('Error:', error)
    })
}

fetchData()

Answer:

async function fetchData() {
  try {
    const response = await fetch('/api/data')
    const data = await response.json()
    console.log('Data:', data)
  } catch (error) {
    console.error('Error:', error)
  }
}

fetchData()

Explanation:

  • Declared fetchData as an async function.
  • Used await to pause execution until promises are resolved.
  • Wrapped await expressions in a try...catch block to handle errors.

Exercise 4: Parallel Execution with Async/Await

Question:

Modify the following code to fetch data from two APIs in parallel using async/await.

async function fetchAllData() {
  const data1 = await fetchData1()
  const data2 = await fetchData2()
  console.log('Data1:', data1)
  console.log('Data2:', data2)
}

fetchAllData()

Answer:

async function fetchAllData() {
  const promise1 = fetchData1()
  const promise2 = fetchData2()

  const data1 = await promise1
  const data2 = await promise2

  console.log('Data1:', data1)
  console.log('Data2:', data2)
}

fetchAllData()

Explanation:

  • Started both fetch operations without awaiting.
  • Awaited both promises after they were initiated.
  • This allows both operations to run in parallel.

Exercise 5: Handling Multiple Promises with Promise.all()

Question:

Using Promise.all(), fetch data from three APIs (fetchData1, fetchData2, fetchData3) and log the results. Handle any errors that occur.

Answer:

Promise.all([fetchData1(), fetchData2(), fetchData3()])
  .then((results) => {
    const [data1, data2, data3] = results
    console.log('Data1:', data1)
    console.log('Data2:', data2)
    console.log('Data3:', data3)
  })
  .catch((error) => {
    console.error('Error fetching data:', error)
  })

Explanation:

  • Used Promise.all() to wait for all promises to fulfill.
  • Destructured the results array.
  • Handled errors with .catch().

Understanding asynchronous programming patterns in JavaScript - from callbacks to promises and async/await - is fundamental for building modern, efficient applications. By mastering these concepts, you'll be better equipped to handle complex asynchronous operations, write cleaner code, and tackle common technical interview questions related to JavaScript's asynchronous nature.

Practice Problems

Explain how promises improve upon callbacks in asynchronous programming.

Loading...

What is the difference between `Promise.all()` and `Promise.race()`?

Loading...

How does `async/await` simplify working with promises?

Loading...

Callback Hell PitfallsDifficulty: Hard

What are the main issues with using callbacks for asynchronous code in JavaScript?

Loading...

Let's continue exploring the next page. Take your time, and proceed when you're ready.

Lesson completed?

Found a bug, typo, or have feedback?

Let me know