Understanding Asynchronous JavaScript with async and await

2026-06-1

Understanding Asynchronous JavaScript with async and await

JavaScript often needs to wait for work that does not finish immediately. An API request takes time, a database must return data, and a timer completes only after its delay.

If JavaScript stopped the entire application while waiting, the interface would feel frozen. Asynchronous code allows other work to continue until the result is ready.

Synchronous and asynchronous code

Synchronous statements run one after another:

console.log("First");
console.log("Second");
console.log("Third");

The output is predictable:

First
Second
Third

A timer behaves differently:

console.log("First");

setTimeout(() => {
  console.log("Second");
}, 1000);

console.log("Third");

The output becomes:

First
Third
Second

JavaScript schedules the callback and continues running the remaining code.

What is a Promise?

A Promise represents a result that may become available later. It can be in one of three states:

  • pending — the operation is still running;
  • fulfilled — the operation completed successfully;
  • rejected — the operation failed.

The Fetch API returns a Promise:

fetch("https://api.example.com/users")
  .then((response) => response.json())
  .then((users) => {
    console.log(users);
  })
  .catch((error) => {
    console.error(error);
  });

This works, but several chained operations can become difficult to read.

Using async and await

An async function always returns a Promise. Inside it, await pauses that function until a Promise settles:

async function getUsers() {
  const response = await fetch("https://api.example.com/users");
  const users = await response.json();

  return users;
}

The code reads from top to bottom, which makes the sequence easier to follow. Only the current async function pauses; JavaScript can continue handling other work.

Handling errors with try and catch

Network requests can fail, so asynchronous code should handle errors:

async function getUsers() {
  try {
    const response = await fetch("https://api.example.com/users");

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

    return await response.json();
  } catch (error) {
    console.error("Could not load users:", error);
    return [];
  }
}

An important detail is that fetch does not reject its Promise for every HTTP error. A 404 or 500 response still needs to be detected with response.ok.

Running independent requests together

This code waits for one request before starting the next:

const users = await fetchUsers();
const posts = await fetchPosts();

If the requests do not depend on each other, start them together:

const [users, posts] = await Promise.all([fetchUsers(), fetchPosts()]);

Promise.all is usually faster for independent tasks because both operations run at the same time. It rejects if any included Promise rejects.

Sequential work is sometimes correct

Parallel execution is not always appropriate. The second operation may need the result of the first:

const user = await createUser(formData);
const profile = await createProfile(user.id);

Here, createProfile cannot begin until createUser provides the user ID.

A common mistake with array methods

Using await inside forEach does not make forEach wait:

items.forEach(async (item) => {
  await saveItem(item);
});

console.log("Finished");

Finished may appear before the items are saved.

For sequential processing, use for...of:

for (const item of items) {
  await saveItem(item);
}

console.log("Finished");

For parallel processing, map the items to Promises:

await Promise.all(items.map((item) => saveItem(item)));

console.log("Finished");

Do not forget to await the result

Calling an async function without await gives you a Promise, not its final value:

async function getName() {
  return "Anand";
}

const name = getName();
console.log(name);

The console displays a Promise. Use:

const name = await getName();

This must run inside another async function or in an environment that supports top-level await.

An example from a URL shortener

A frontend can submit a long URL and wait for the shortened result:

async function shortenUrl(url) {
  const response = await fetch("/api/shorten", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ url }),
  });

  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error || "Could not shorten the URL");
  }

  return data.shortUrl;
}

The UI can use try, catch, and finally to manage loading and error states:

try {
  setIsLoading(true);
  const shortUrl = await shortenUrl(longUrl);
  setShortUrl(shortUrl);
} catch (error) {
  setError(error.message);
} finally {
  setIsLoading(false);
}

Practical rules

  • Use async and await when they make the sequence easier to read.
  • Check response.ok when using fetch.
  • Wrap operations that can fail in try and catch.
  • Use Promise.all for independent tasks.
  • Use for...of when operations must run sequentially.
  • Avoid combining forEach with async callbacks.
  • Always decide what the interface should show while a request is running.

Conclusion

Asynchronous JavaScript becomes easier once you stop thinking of await as freezing the whole application. It pauses only the current async function while the runtime continues processing other work.

Promises describe future results, and async and await provide a readable way to work with them. The most important skills are choosing between parallel and sequential execution, handling failures, and keeping the user informed while an operation is in progress.