Asynchronous JavaScript

Asynchronous JavaScript

JavaScript runs one thing at a time, yet it never freezes while waiting. The event loop, callbacks, promises and async/await are the machinery that makes that possible.

intermediate16 min readUpdated Sep 15, 2026
js
// user.js
async function loadUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error("User not found");
  return res.json();
}

const user = await loadUser(42);
console.log(user.name);
Execution model
Single-threaded event loop
First solution
Callbacks
Modern solution
Promises + async/await
Promise states
pending, fulfilled, rejected
Run in parallel
Promise.all / allSettled
Keyword
async / await

Hands-on

Watch the event loop

The order of the messages shows how the event loop schedules work.

Watch the event loop

The order of the messages shows how the event loop schedules work.

Why it matters

Why async matters

Never blocks the page

Slow work happens off the main thread while the UI stays responsive, so users never stare at a frozen tab.

Reads like sync code

async/await lets you write asynchronous logic top to bottom, without nesting callbacks or chaining then blocks.

Built for the network

Every fetch, timer, file read and database call in modern JavaScript is asynchronous, so this is core, not optional.

The big picture

The three pieces of the puzzle

Async JavaScript is not one feature but three layers stacked on top of each other: the event loop, promises, and the syntax that reads like sync code.

The event loop

Scheduling

One call stack, a task queue and a microtask queue decide the order in which your code actually runs.

Promises

Representation

An object that represents a future value, with a clean way to chain work and handle failure.

async / await

Syntax

A thin layer over promises that makes asynchronous code look and read like ordinary synchronous code.

Async at a glance

The tools in your async toolkit

The event loop

Runs queued work once the current synchronous code has finished.

Callbacks

The original pattern — pass a function to run when the work completes.

Promises

Objects with three states that let you chain success and failure handlers.

Combinators

all, allSettled, race and any coordinate multiple promises at once.

async / await

Pause a function at await without blocking the rest of the program.

Error handling

catch and try/catch turn rejected promises into normal, handleable errors.

A short history

From callbacks to async/await

  1. 2005

    AJAX popularises callbacks

    Gmail and Google Maps show the world that pages can update without a reload.

    05
  2. 2009

    Node.js embraces async

    Server-side JavaScript makes non-blocking I/O the default way to build backends.

    09
  3. 2015

    Promises in ES6

    The Promise object is standardised, giving async code a shared contract.

    15
  4. 2017

    async / await

    ES2017 adds the syntax that makes asynchronous code read like synchronous code.

    17
  5. Today

    Async everywhere

    Fetch, streams, workers and most modern APIs are promise-based by default.

    Today

The complete guide

Asynchronous JavaScript: Everything you need to know

Why asynchronous JavaScript exists

Computers do many things slowly: reading files, querying databases, waiting for a server to respond. If JavaScript stopped and waited for each one, the page would freeze and users would leave. Asynchronous programming is how the language keeps working while slow tasks happen elsewhere.

The mental model is a restaurant. A single waiter takes your order, passes it to the kitchen, and immediately serves another table. When your food is ready, the kitchen rings a bell and the waiter brings it over. The waiter never stands in the kitchen watching the food cook. JavaScript is that waiter, and the event loop is the bell.

The call stack and the event loop

JavaScript has one call stack. Every function call pushes a frame; when it returns, the frame pops. Because there is only one stack, only one thing runs at a time.

Asynchronous APIs are provided by the host environment, not the language. When you call setTimeout, fetch or a file read, the browser or Node.js handles the waiting. When the work finishes, its callback is placed on a queue. The event loop constantly checks: is the call stack empty? If so, take the next callback from the queue and run it.

// order.js
console.log("start");

setTimeout(() => console.log("timeout"), 0);

Promise.resolve().then(() => console.log("promise"));

console.log("end");

// start, end, promise, timeout

Two details matter. First, synchronous code always finishes before any callback runs. Second, there are two queues: microtasks (promise callbacks) run immediately after the current task, before tasks (timers, I/O). That is why promise logs before timeout.

Callbacks

The original way to handle asynchronous results is a callback — a function you pass in to be called later.

// callback.js
function getUser(id, callback) {
  setTimeout(() => {
    callback({ id, name: "Ada" });
  }, 300);
}

getUser(1, (user) => {
  console.log(user.name);
});

Callbacks work, but they compose poorly. When each step depends on the last, you nest, and the result is callback hell: code that marches diagonally across the screen and makes error handling painful.

// hell.js
getUser(1, (user) => {
  getPosts(user.id, (posts) => {
    getComments(posts[0].id, (comments) => {
      // three levels deep and counting
    });
  });
});

Promises

A Promise is an object that represents a value that may not exist yet. It is in one of three states:

  • pending — the work is still running.
  • fulfilled — it finished successfully with a value.
  • rejected — it failed with a reason.

Once settled, a promise never changes state. You attach handlers with then, catch and finally.

// promise.js
function getUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id > 0) resolve({ id, name: "Ada" });
      else reject(new Error("Invalid id"));
    }, 300);
  });
}

getUser(1)
  .then((user) => console.log(user.name))
  .catch((error) => console.error(error.message))
  .finally(() => console.log("done"));

Chaining is the key advantage. Each then returns a new promise, so instead of nesting you can flatten the sequence and handle every error in a single catch at the end.

// chain.js
getUser(1)
  .then((user) => getPosts(user.id))
  .then((posts) => getComments(posts[0].id))
  .then((comments) => console.log(comments))
  .catch((error) => console.error("Something failed:", error));

Combining promises

When several asynchronous operations are involved, the Promise constructor provides combinators.

// combine.js
const [user, posts, settings] = await Promise.all([
  getUser(1),
  getPosts(1),
  getSettings(),
]);

const results = await Promise.allSettled([getUser(1), getUser(-1)]);

const fastest = await Promise.race([getFromCache(), getFromNetwork()]);

const firstSuccess = await Promise.any([mirrorA(), mirrorB()]);

Promise.all runs everything in parallel and rejects if any promise rejects. Promise.allSettled waits for all and reports each outcome. Promise.race settles with the first promise to settle, and Promise.any resolves with the first to succeed. Choosing the right one changes your error behaviour dramatically.

async and await

async and await are syntax built on top of promises. Marking a function async makes it always return a promise. Inside it, await pauses the function until a promise settles, then continues with the resolved value — without blocking the rest of the program.

// await.js
async function loadDashboard() {
  try {
    const user = await getUser(1);
    const posts = await getPosts(user.id);
    return { user, posts };
  } catch (error) {
    console.error("Failed to load dashboard", error);
    throw error;
  }
}

This reads like synchronous code, which is the whole point. Errors are handled with ordinary try/catch, and finally still runs. The function returns a promise, so callers can await it or chain .then as usual.

Sequential vs parallel

A subtle but expensive mistake is awaiting independent work one item at a time. Each await waits for the previous one, so the total time is the sum. When the tasks do not depend on each other, start them together.

// parallel.js
// Sequential — slow, ~600ms
const a = await slowTask("a");
const b = await slowTask("b");

// Parallel — fast, ~300ms
const [x, y] = await Promise.all([slowTask("x"), slowTask("y")]);

Use sequential awaits only when a later step genuinely needs an earlier result, such as fetching a user before fetching that user’s posts.

Error handling in async code

Rejected promises that nobody handles become unhandled rejections, which can crash Node.js processes and fill the console in browsers.

// errors.js
try {
  const res = await fetch("/api/data");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
} catch (error) {
  showMessage("Could not load data. Please try again.");
} finally {
  hideSpinner();
}

Prefer try/catch around awaited calls, throw Error objects (not strings), and always handle or deliberately re-throw. The Error Handling guide goes deeper on strategies and user-facing messaging.

Common pitfalls

  • Forgetting await. The function returns a promise, not the value, so the next line runs too early.
  • forEach with async callbacks. forEach ignores returned promises; use for...of or Promise.all with map.
  • Awaiting in a loop when parallel would do. It works but is needlessly slow.
  • Not returning inside then. A missing return breaks the chain and loses the value.
  • Assuming order. Only await and microtask ordering guarantee sequence; concurrent tasks finish whenever they finish.
  • Swallowing errors with an empty catch, hiding the bug you needed to see.

Best practices

  • Prefer async/await over long .then chains for readability.
  • Run independent work in parallel with Promise.all or Promise.allSettled.
  • Always handle rejections, even if only to log and re-throw.
  • Keep async functions small and give them one responsibility.
  • Use Promise.allSettled when partial results are acceptable.
  • Cancel work you no longer need with an AbortController.
  • Show loading and error states so users understand what is happening.

Where to go next

You now have the full async toolkit. Put it to work with the Fetch API, where promises and await drive every network request, and harden it with the patterns in Error Handling.

Chaining vs awaiting

async/await removes nested callbacks and makes the sequence obvious.

Prefer
async function load() {
  const user = await getUser(1);
  const posts = await getPosts(user.id);
  return posts;
}
Avoid
function load() {
  return getUser(1).then((user) => {
    return getPosts(user.id).then((posts) => {
      return posts;
    });
  });
}

Independent work

If the requests do not depend on each other, start them together with Promise.all.

Prefer
const [user, posts] = await Promise.all([
  getUser(1),
  getPosts(1),
]);
Avoid
const user = await getUser(1);
const posts = await getPosts(1);
// runs one after the other

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Async JavaScript?

Our interactive tutorial walks you through Async JavaScript step by step — with quizzes and real code you can run in the browser.