Skip to content
JavaScript

Promises and async/await in JavaScript, Explained

By The EbookWale Team · Updated June 16, 2026 · 5 min read

Promises represent a value that arrives later; async/await lets you write asynchronous code that reads top to bottom. Here's how both work, how the event loop fits in, and the mistakes to avoid.

A promise is an object that represents a value you do not have yet — the result of something that finishes later, like a network request. async/await is cleaner syntax over promises that lets you write asynchronous code that reads top to bottom, instead of nesting callbacks.

Asynchronous code is where many self-taught developers stall, because it breaks the comforting illusion that programs run strictly line by line. They mostly do — but when you ask the network for data, JavaScript does not freeze and wait. Understanding what it does instead is the whole game, and it is a guaranteed interview topic.

Why async exists at all

JavaScript runs on a single thread. If it stopped and waited every time it fetched data from a server (which can take seconds), the entire page would freeze — no clicks, no scrolling, nothing. So slow operations are handed off, and your code keeps running. When the result is ready, your callback is invited back in.

This is why an async result cannot just be “returned” like a normal value: at the moment your line of code runs, the value genuinely does not exist yet.

Callbacks: the original pattern (and its problem)

The first way to handle “do this when it’s ready” was the callback — a function you pass in to be called later.

getUser(1, (user) => {
  getPosts(user.id, (posts) => {
    getComments(posts[0].id, (comments) => {
      // ...the "pyramid of doom"
    });
  });
});

It works, but nesting dependent calls produces deeply indented “callback hell” that is hard to read and harder to error-handle. Promises were created to flatten this.

Promises: a value that arrives later

A promise is in one of three states: pending, fulfilled (it worked, here’s the value), or rejected (it failed, here’s the error). You react with .then() for success and .catch() for failure.

fetch("/api/users/1")
  .then((res) => res.json())
  .then((user) => console.log(user.name))
  .catch((err) => console.error("Failed:", err));

The chain reads as a sequence of steps, each receiving the previous step’s result. Crucially, returning a promise from inside .then() flattens it — no more pyramid.

🔑 REMEMBER — A promise does not make anything faster. It is a placeholder for a future value and a clean way to attach "what to do when it arrives" and "what to do if it fails."

async/await: promises that read like normal code

async/await is the modern syntax, and it is just promises underneath. Mark a function async, and inside it you can await any promise — the function pauses there until the promise settles, then continues with the value.

async function loadUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new Error("Not found");
    const user = await res.json();
    return user;            // an async function always returns a promise
  } catch (err) {
    console.error("Failed:", err);
  }
}

Two things to internalise:

  • await pauses only this function, not the whole program. The event loop keeps serving everything else.
  • Every async function returns a promise. So to use the result elsewhere, you await it again (or .then() it). This surprises beginners who expect a plain value back.

Errors are handled with ordinary try/catch, which is the biggest ergonomic win over .catch() chains.

Running things in parallel with Promise.all

await in sequence means waiting in sequence. If three requests do not depend on each other, awaiting them one by one is wasteful:

// Slow: 3 round-trips back to back
const a = await getA();
const b = await getB();
const c = await getC();

// Fast: all three at once, then wait for the slowest
const [a, b, c] = await Promise.all([getA(), getB(), getC()]);

Promise.all runs them concurrently and resolves when all are done — or rejects the moment any one fails. If you want every result regardless of individual failures, use Promise.allSettled.

The event loop: what actually happens

Here is the model that ties it together, and the part interviews dig into.

JavaScript has a call stack (what’s running now), and the host (browser/Node) provides timers and network APIs. When an async task finishes, its callback is placed in a queue. The event loop does one job: when the call stack is empty, it takes the next callback from the queue and runs it.

Call Stack main() fn() Microtask queue .then() · await Macrotask queue setTimeout · events event loop drains FIRST, fully one per tick
When the stack is empty, microtasks drain before the next macrotask.

There are two queues, and the order matters:

QueueHoldsPriority
Microtask queueresolved promises, await continuationsdrained first, fully, after each task
Macrotask queuesetTimeout, events, I/Oone per loop iteration

This is why a resolved promise’s .then runs before a setTimeout(…, 0) queued earlier:

console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// Output: 1, 4, 3, 2

Synchronous code (1, 4) runs first; then the microtask (3) drains before the macrotask (2). If you can explain that output, you understand the event loop.

Common mistakes

  • Forgetting that async functions return promises. const u = loadUser(1) gives you a promise, not a user. You need await.
  • await inside a forEach. forEach ignores the returned promise, so it does not wait. Use a for...of loop, or Promise.all(items.map(...)).
  • Sequential awaits for independent work. Reach for Promise.all.
  • Swallowing errors. An unhandled rejected promise can fail silently. Always try/catch around await, or .catch() a chain.
⚠️ GOTCHA — items.forEach(async (x) => { await save(x); }) does not wait for the saves to finish. Use for (const x of items) { await save(x); } for sequence, or await Promise.all(items.map(save)) for parallel.

Where this sits in the bigger picture

Async is Phase 4 of our JavaScript roadmap, and it leans on closures — every callback closes over its surroundings, so it helps to have closures solid first.

The event loop, microtasks, and Promise.all patterns are drawn out step by step in JavaScript in Three Months, the job-ready tier — and pushed further (cancellation, concurrency limits, async iterators) in JavaScript for Staff Engineers.

Get comfortable here and asynchronous JavaScript stops feeling like magic and starts feeling like a queue you can reason about.

Frequently asked questions

What is the difference between a promise and async/await?

They are the same machinery with different syntax. A promise is an object representing a value that will be available later. async/await is syntactic sugar over promises that lets you write asynchronous code as if it were synchronous, top to bottom, using try/catch for errors. Under the hood, await pauses on a promise.

Does async/await block the page?

No. await pauses only the async function it is inside; the rest of your program and the browser keep running. JavaScript is single-threaded but non-blocking — while one function awaits, the event loop handles other work.

When should I use Promise.all?

Use Promise.all when you have several independent async operations and want them to run at the same time rather than one after another. It resolves when all of them resolve, or rejects as soon as any one rejects. For 'don't fail if one fails', use Promise.allSettled.

Why is my async function returning a Promise instead of a value?

Every async function returns a promise — that is by definition. To get the value out, await it inside another async function, or use .then(). You cannot use a bare await at the top level of a regular script unless it is a module.