Index

Asynchronous JavaScript

JavaScript for Beginners

16.1 Synchronous vs Asynchronous Execution

Understanding the difference between synchronous and asynchronous execution is key to mastering JavaScript programming.

What Is Synchronous Execution?

Synchronous code runs one line at a time, in order. Each operation must finish before the next one starts — much like standing in a single line at a coffee shop. You wait patiently for the person ahead of you to finish before it’s your turn.

In JavaScript, this means the program executes statements sequentially, blocking further code from running until the current operation completes.

console.log("Step 1");
console.log("Step 2");
console.log("Step 3");

The output will always be:

Step 1
Step 2
Step 3

What Is Asynchronous Execution?

Asynchronous code, on the other hand, allows your program to start a task and move on to others before that task finishes — like cooking dinner while waiting for laundry to finish. You don’t just stand idle; you multitask.

In JavaScript, this means some operations (like fetching data from the internet or reading files) happen in the background. Your program can continue running without waiting, and when the background task finishes, a callback function handles the result.

Why Asynchronous JavaScript?

JavaScript runs in a single thread, so synchronous long-running tasks (like network requests) would freeze the program and make the user interface unresponsive. Asynchronous programming prevents this by allowing these operations to happen without blocking other code.

The Event Loop and Callbacks (Brief Introduction)

JavaScript uses an event loop to manage asynchronous operations. When an async task completes, its callback function is queued and executed once the main code finishes running. This allows your program to handle multiple tasks efficiently without freezing.

Example:

console.log("Start");

setTimeout(() => {
  console.log("Async task done");
}, 1000);

console.log("End");

Output:

Start
End
Async task done

Here, setTimeout schedules the callback to run after 1 second, but the program continues executing and logs "End" immediately.

Summary

In the next sections, we’ll explore how JavaScript implements asynchronous programming using callbacks, promises, and the modern async/await syntax.

Index

16.2 Callbacks

Callbacks are one of the fundamental building blocks of asynchronous JavaScript. A callback is simply a function that is passed as an argument to another function and executed later, usually once some task completes.

How Callbacks Work

In JavaScript, functions are first-class objects, meaning they can be passed around just like any other value. When you pass a function as an argument, the receiving function can call it whenever appropriate.

Basic Example

function greet(name, callback) {
  console.log(`Hello, ${name}!`);
  callback();
}

function sayGoodbye() {
  console.log("Goodbye!");
}

greet("Alice", sayGoodbye);

Output:

Hello, Alice!
Goodbye!

Here, sayGoodbye is passed as a callback to greet and executed after the greeting.

Callbacks in Asynchronous Tasks

Callbacks are especially useful for asynchronous operations like timers, reading files, or handling events.

Example: Using setTimeout

console.log("Start");

setTimeout(() => {
  console.log("This happens after 2 seconds");
}, 2000);

console.log("End");

Output:

Start
End
This happens after 2 seconds

The anonymous function passed to setTimeout is the callback that runs after the delay.

Callbacks in Event Handling

When responding to user actions, you often pass callbacks to event listeners:

document.getElementById("btn").addEventListener("click", () => {
  alert("Button clicked!");
});

The function you provide is called when the button is clicked.

Callback Hell: When Things Get Messy

While callbacks are powerful, nesting them too deeply leads to callback hell — code that is hard to read, maintain, and debug.

Example of Callback Hell

loginUser(username, password, (error, user) => {
  if (error) {
    console.error(error);
  } else {
    fetchUserProfile(user.id, (error, profile) => {
      if (error) {
        console.error(error);
      } else {
        updateUI(profile, (error) => {
          if (error) {
            console.error(error);
          } else {
            console.log("UI updated successfully");
          }
        });
      }
    });
  }
});

This pyramid-like structure is difficult to follow and leads to bugs and frustration.

Why Callbacks Alone Are Not Enough

Because of these issues, JavaScript introduced better abstractions like Promises and async/await, which simplify asynchronous code.

Summary

Callbacks are the foundation of async JavaScript — mastering them will help you understand how asynchronous flow works before moving to modern patterns.

Index

16.3 Promises

Promises are a modern way to handle asynchronous operations in JavaScript, designed to improve upon the limitations of callbacks. They make asynchronous code easier to read, write, and maintain.

What Is a Promise?

A Promise represents the eventual result of an asynchronous operation. It’s like a placeholder that promises to deliver a value in the future — or an error if something goes wrong.

Promise States

A Promise can be in one of three states:

Once a Promise settles (fulfilled or rejected), its state cannot change.

Creating a Promise

You create a Promise using the Promise constructor, which takes a function with two parameters: resolve (for success) and reject (for failure).

const delay = (ms) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(`Waited for ${ms} milliseconds`);
    }, ms);
  });
};

delay(2000).then((message) => {
  console.log(message);
});

In this example, the promise waits for 2 seconds, then resolves with a message.

Using .then(), .catch(), and .finally()

delay(1000)
  .then((msg) => {
    console.log(msg);  // Waited for 1000 milliseconds
    throw new Error("Oops!");  // Simulate an error
  })
  .catch((error) => {
    console.error("Error:", error.message);
  })
  .finally(() => {
    console.log("Done waiting.");
  });

Chaining Promises

One of the biggest advantages of Promises is chaining, which lets you perform a sequence of asynchronous operations clearly:

fetch("https://api.example.com/data")
  .then((response) => response.json())
  .then((data) => {
    console.log("Data received:", data);
    return delay(1500);  // Wait before next step
  })
  .then(() => {
    console.log("Ready for next operation.");
  })
  .catch((error) => {
    console.error("Fetch error:", error);
  });

Here, each .then() returns either a value or another Promise, making the flow easy to follow.

Summary

Next, we’ll see how the async/await syntax builds on Promises to make asynchronous code look even more like synchronous code!

Index

16.4 async/await

The async/await syntax is a modern, cleaner way to write asynchronous JavaScript code. It builds on Promises and allows you to write asynchronous operations that look and behave like synchronous code, making it easier to read, write, and debug.

What Are async and await?

Basic Usage

Here’s an example that fetches data using Promises and async/await:

function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("Data fetched!");
    }, 2000);
  });
}

// Using async/await
async function getData() {
  console.log("Fetching data...");
  const result = await fetchData();
  console.log(result);
}

getData();

Output:

Fetching data...
(Data appears after 2 seconds)
Data fetched!

The await fetchData() line pauses the function until the Promise resolves, then continues with the result.

Comparison With Promises

Using Promises, the same code looks like this:

fetchData().then(result => {
  console.log(result);
});

While Promises work fine, chaining multiple asynchronous steps with .then() can get messy. With async/await, you write code in a linear, synchronous style that’s easier to follow, especially when dealing with several async calls.

Handling Errors with try/catch

One of the biggest advantages of async/await is simplified error handling using traditional try/catch blocks:

async function getDataWithError() {
  try {
    const result = await fetchDataThatMightFail();
    console.log(result);
  } catch (error) {
    console.error("Error occurred:", error);
  }
}

This is cleaner than .catch() methods on Promises, especially with complex async flows.

Example: Multiple Awaited Calls

async function processTasks() {
  console.log("Task 1 start");
  await delay(1000);
  console.log("Task 1 complete");

  console.log("Task 2 start");
  await delay(1500);
  console.log("Task 2 complete");
}

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

processTasks();

The output shows tasks running in sequence, with the function pausing at each await:

Task 1 start
(Task 1 waits 1 second)
Task 1 complete
Task 2 start
(Task 2 waits 1.5 seconds)
Task 2 complete

Summary

In the next section, we’ll dive deeper into how to handle errors effectively in asynchronous code to write robust, reliable JavaScript applications.

Index

16.5 Error Handling in Async Code

Handling errors effectively in asynchronous JavaScript is essential to building reliable and maintainable applications. Errors can occur during network requests, file operations, or any async task, so knowing how to catch and respond to them is crucial.

Handling Errors with Promises: .catch()

When working with Promises, you handle errors using the .catch() method, which catches any rejection or thrown error in the Promise chain.

Example:

fetch("https://api.example.com/data")
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json();
  })
  .then((data) => {
    console.log("Data received:", data);
  })
  .catch((error) => {
    console.error("Fetch error:", error.message);
  });

Common Pitfalls with Promises

Handling Errors with async/await: try/catch

When using async/await, error handling becomes more intuitive with try/catch blocks, similar to synchronous code.

Example:

async function getUserData() {
  try {
    const response = await fetch("https://api.example.com/user");
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const data = await response.json();
    console.log("User data:", data);
  } catch (error) {
    console.error("Error fetching user data:", error.message);
  }
}

getUserData();

Best Practices for Robust Async Error Handling

Summary

Pattern How to Handle Errors
Promises Use .catch() to catch rejections
async/await Use try/catch blocks

Proper error handling is a vital part of asynchronous programming in JavaScript. By catching and managing errors effectively, your applications will be more stable, easier to debug, and provide a better user experience.

Index