Skip to main content

Async JavaScript

JavaScript is single-threaded, meaning it can only do one thing at a time. Asynchronous programming lets you start long-running operations (network requests, timers, file reads) without blocking the rest of your code.

The Event Loop

The event loop is how JavaScript handles asynchronous code. Understanding it is fundamental.

console.log("1 — Start");

setTimeout(() => {
  console.log("2 — Timeout callback");
}, 0);

console.log("3 — End");

// Output:
// 1 — Start
// 3 — End
// 2 — Timeout callback

Even with a 0ms delay, the timeout callback runs after the synchronous code finishes. The event loop processes the call stack first, then checks the callback queue.

Callbacks

A callback is a function passed to another function to be called later.

function fetchData(url, callback) {
  setTimeout(() => {
    const data = { id: 1, name: "Sabaoon" };
    callback(null, data);
  }, 1000);
}

fetchData("/api/user", (error, data) => {
  if (error) {
    console.error("Failed:", error);
    return;
  }
  console.log("User:", data.name);
});

Callback Hell

Nested callbacks quickly become unreadable:

getUser(userId, (err, user) => {
  getOrders(user.id, (err, orders) => {
    getOrderDetails(orders[0].id, (err, details) => {
      getShipping(details.shippingId, (err, shipping) => {
        console.log("Shipping:", shipping);
        // deeply nested and hard to maintain
      });
    });
  });
});

This is why promises were invented.

Promises

A Promise represents a value that may not be available yet. It has three states: pending, fulfilled, or rejected.

Creating a Promise

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

function fetchUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id <= 0) {
        reject(new Error("Invalid user ID"));
      } else {
        resolve({ id, name: "User " + id });
      }
    }, 500);
  });
}

Consuming Promises with .then() and .catch()

fetchUser(1)
  .then((user) => {
    console.log("User:", user.name);
    return user.id;
  })
  .then((id) => {
    console.log("User ID:", id);
  })
  .catch((error) => {
    console.error("Error:", error.message);
  })
  .finally(() => {
    console.log("Request complete");
  });

Promise Chaining

Chaining flattens the callback hell pattern:

getUser(userId)
  .then((user) => getOrders(user.id))
  .then((orders) => getOrderDetails(orders[0].id))
  .then((details) => getShipping(details.shippingId))
  .then((shipping) => console.log("Shipping:", shipping))
  .catch((error) => console.error("Failed:", error));

Async/Await

async/await is syntactic sugar over promises that makes asynchronous code look synchronous.

async function loadUser(id) {
  try {
    const user = await fetchUser(id);
    console.log("User:", user.name);
    return user;
  } catch (error) {
    console.error("Failed to load user:", error.message);
    throw error;
  }
}

loadUser(1);

Rewriting the Callback Hell Example

async function getShippingInfo(userId) {
  try {
    const user = await getUser(userId);
    const orders = await getOrders(user.id);
    const details = await getOrderDetails(orders[0].id);
    const shipping = await getShipping(details.shippingId);

    console.log("Shipping:", shipping);
    return shipping;
  } catch (error) {
    console.error("Failed:", error.message);
  }
}

Clean, readable, and easy to debug.

The Fetch API

fetch is the modern way to make HTTP requests in the browser.

GET Request

async function getPost(id) {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${id}`
  );

  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }

  const post = await response.json();
  console.log(post.title);
  return post;
}

POST Request

async function createPost(title, body) {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title, body, userId: 1 }),
  });

  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }

  return response.json();
}

Always Check response.ok

A common mistake is forgetting that fetch does not throw on HTTP errors like 404 or 500. You must check response.ok manually.

Running Promises in Parallel

Promise.all

Wait for all promises to resolve. Rejects if any one fails.

async function loadDashboard(userId) {
  const [user, orders, notifications] = await Promise.all([
    fetchUser(userId),
    fetchOrders(userId),
    fetchNotifications(userId),
  ]);

  return { user, orders, notifications };
}

Promise.allSettled

Wait for all promises to complete, regardless of success or failure.

const results = await Promise.allSettled([
  fetch("/api/users"),
  fetch("/api/posts"),
  fetch("/api/broken-endpoint"),
]);

results.forEach((result, i) => {
  if (result.status === "fulfilled") {
    console.log(`Request ${i}: Success`);
  } else {
    console.log(`Request ${i}: Failed — ${result.reason}`);
  }
});

Promise.race

Returns the result of whichever promise settles first.

async function fetchWithTimeout(url, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error("Request timed out")), ms)
  );

  const request = fetch(url);
  return Promise.race([request, timeout]);
}

Error Handling Patterns

Try/Catch with Async/Await

async function safeFetch(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return { data: await response.json(), error: null };
  } catch (error) {
    return { data: null, error: error.message };
  }
}

const { data, error } = await safeFetch("/api/user");
if (error) {
  console.error("Request failed:", error);
} else {
  console.log("Data:", data);
}

Practical Exercise

Build a simple API data loader:

async function loadUserProfile(userId) {
  console.log("Loading profile...");

  try {
    const [userRes, postsRes] = await Promise.all([
      fetch(`https://jsonplaceholder.typicode.com/users/${userId}`),
      fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`),
    ]);

    if (!userRes.ok || !postsRes.ok) {
      throw new Error("Failed to load profile data");
    }

    const user = await userRes.json();
    const posts = await postsRes.json();

    const profile = {
      name: user.name,
      email: user.email,
      postCount: posts.length,
      latestPosts: posts.slice(0, 3).map((p) => p.title),
    };

    console.log(`${profile.name} (${profile.email})`);
    console.log(`${profile.postCount} posts`);
    profile.latestPosts.forEach((title) => console.log(`  - ${title}`));

    return profile;
  } catch (error) {
    console.error("Failed to load profile:", error.message);
    return null;
  }
}

loadUserProfile(1);

Key Takeaways

  • JavaScript uses the event loop to handle async operations on a single thread.
  • Promises replace nested callbacks with chainable .then() calls.
  • async/await makes promise-based code read like synchronous code.
  • Always check response.ok when using fetch — it does not throw on HTTP errors.
  • Use Promise.all for parallel requests and Promise.allSettled when you need results from all regardless of failures.