DEV Community

Suman Bhattarai
Suman Bhattarai

Posted on

🚀 Understanding JavaScript Async/Await, Promises, and the Event Loop (In the Easiest Way)

JavaScript is single-threaded, yet it can:

  • Fetch data from servers
  • Read files
  • Handle user clicks
  • Run timers
  • Animate UI

All without freezing the browser.

How? Through Promises, async/await, and the Event Loop.

This guide is written to make the “why is my code running out of order?” problem finally click. We’ll cover:

  • What synchronous vs asynchronous code means
  • What Promises are (and what they aren’t)
  • How async/await works under the hood
  • How the Event Loop decides what runs next
  • Microtasks vs macrotasks (why Promises often run before timers)
  • Common mistakes and best practices
  • Real-world patterns (parallel requests, error handling)

🧠 1) Synchronous vs Asynchronous JavaScript

✅ Synchronous (blocking, predictable order)

console.log("A");
console.log("B");
console.log("C");
Enter fullscreen mode Exit fullscreen mode

Output:

A
B
C
Enter fullscreen mode Exit fullscreen mode

Each line waits for the previous one to finish.

❌ The real world has slow work

Network requests, file reads, databases, timers—these don’t finish instantly. If JavaScript “waited” for them the same way, your app would freeze.

Example:

console.log("Start");

setTimeout(() => {
  console.log("Done");
}, 3000);

console.log("End");
Enter fullscreen mode Exit fullscreen mode

Output:

Start
End
Done
Enter fullscreen mode Exit fullscreen mode

JavaScript doesn’t pause the whole program for setTimeout. It schedules the callback to run later.

Important idea:

Asynchronous code lets JavaScript keep the UI responsive and continue running other tasks while waiting for slow operations.


🌟 2) What is a Promise?

A Promise represents a value you don’t have yet, but will have later (or an error if it fails).

A Promise can be in one of three states:

  • Pending: still working
  • Fulfilled: completed successfully (resolved)
  • Rejected: failed (rejected)

A simple Promise

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("Data received!");
  }, 2000);
});

myPromise.then(result => {
  console.log(result);
});
Enter fullscreen mode Exit fullscreen mode

After 2 seconds you’ll see:

Data received!
Enter fullscreen mode Exit fullscreen mode

Resolve vs Reject (success vs failure)

function doWork(success) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (success) resolve("✅ Work succeeded");
      else reject(new Error("❌ Work failed"));
    }, 1000);
  });
}

doWork(true)
  .then(msg => console.log(msg))
  .catch(err => console.log(err.message));
Enter fullscreen mode Exit fullscreen mode

Promise mental model (easy)

Think: “I’ll give you a result later.”
You attach handlers for later using:

  • .then(...) for success
  • .catch(...) for errors
  • .finally(...) for cleanup

🟡 3) Consuming Promises with .then() / .catch()

Promises are commonly used with .then() and .catch():

fetch("https://jsonplaceholder.typicode.com/users")
  .then(response => response.json())
  .then(users => {
    console.log("Users:", users.length);
  })
  .catch(error => {
    console.log("Fetch error:", error);
  });
Enter fullscreen mode Exit fullscreen mode

Why chaining can get messy

This works, but long chains can become harder to read, especially with multiple steps and error handling.

That’s why async/await exists: it’s Promise syntax that reads more like normal code.


✨ 4) async / await — Promises, but readable

The rules (must know)

  • async before a function means the function always returns a Promise
  • await can only be used inside an async function (with some modern exceptions like top-level await in modules)
  • await pauses only the async function, not the entire program

The same fetch example using async/await

async function getUsers() {
  const response = await fetch("https://jsonplaceholder.typicode.com/users");
  const users = await response.json();
  console.log("Users:", users.length);
}

getUsers();
Enter fullscreen mode Exit fullscreen mode

Cleaner, right?

✅ Key: await does NOT freeze JavaScript

This example proves it:

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

async function demo() {
  console.log("1. inside demo: start");
  await wait(2000);
  console.log("3. inside demo: after await");
}

console.log("0. outside: before demo");
demo();
console.log("2. outside: after demo");
Enter fullscreen mode Exit fullscreen mode

Output:

0. outside: before demo
1. inside demo: start
2. outside: after demo
(wait 2 seconds)
3. inside demo: after await
Enter fullscreen mode Exit fullscreen mode

What happened?

  • The async function demo() paused at await wait(2000)
  • But the code outside continued running immediately

So no: your entire program does not “get stuck.” Only the async function waits.


🧯 5) Error handling with try/catch

With .then() you typically handle errors using .catch().

With async/await, the clean pattern is try/catch:

async function loadUser() {
  try {
    const res = await fetch("https://jsonplaceholder.typicode.com/users/1");
    if (!res.ok) throw new Error(`HTTP ${res.status}`);

    const user = await res.json();
    console.log("User:", user.name);
  } catch (err) {
    console.log("Failed:", err.message);
  }
}

loadUser();
Enter fullscreen mode Exit fullscreen mode

Tip: Always check res.ok for fetch. Fetch only rejects for network errors; HTTP 404/500 still resolve as responses.


🔄 6) The Event Loop — the “traffic controller” of JavaScript

If JavaScript is single-threaded, how does it handle asynchronous work?

It uses a system involving:

  • Call Stack (what is running right now)
  • Web APIs (browser features like timers, network)
  • Task Queues (where callbacks wait)
  • Event Loop (moves callbacks onto the stack when ready)

The moving parts (in simple words)

  • Call Stack: Executes your JS functions line by line.
  • Web APIs: Handles async operations (timers, fetch, DOM events) outside the stack.
  • Queues: When async work finishes, callbacks are placed into queues.
  • Event Loop: When the stack is empty, it pushes queued callbacks back onto the stack to run.

⚡ 7) Microtasks vs Macrotasks (why Promises run before timers)

There are (at least) two important queues:

  1. Microtask Queue — Promise callbacks (.then, await continuation), queueMicrotask, MutationObserver
  2. Macrotask (Task) QueuesetTimeout, setInterval, UI events, message events

Priority rule:

When the call stack becomes empty:

  1. Run all microtasks first (drain microtask queue)
  2. Then run one macrotask
  3. Then microtasks again
  4. Repeat

This is why Promise callbacks often run before setTimeout(..., 0).

Example: Promise vs setTimeout

console.log("Start");

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

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

console.log("End");
Enter fullscreen mode Exit fullscreen mode

Output:

Start
End
Promise
Timeout
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • Synchronous logs run first (Start, End)
  • Promise .then goes to microtask queue
  • setTimeout callback goes to macrotask queue
  • Event Loop drains microtasks first → Promise
  • Then runs timer → Timeout

🧩 8) How async/await uses the Event Loop

await is basically syntax sugar for Promise chaining. Internally, code after await becomes a .then(...) continuation (a microtask).

Example:

async function demo() {
  console.log("A");
  await Promise.resolve();
  console.log("B");
}

console.log("C");
demo();
console.log("D");
Enter fullscreen mode Exit fullscreen mode

Output:

C
A
D
B
Enter fullscreen mode Exit fullscreen mode

Why?

  • demo() starts immediately → prints A
  • hits await → schedules the rest (console.log("B")) as a microtask
  • control returns to the main flow → prints D
  • microtask runs → prints B

🧪 9) A more “realistic” event loop demo

Try predicting the output before reading it:

console.log("1");

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

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

(async () => {
  console.log("4 - async start");
  await Promise.resolve();
  console.log("5 - async after await");
})();

console.log("6");
Enter fullscreen mode Exit fullscreen mode

Expected output:

1
4 - async start
6
3 - promise
5 - async after await
2 - timeout
Enter fullscreen mode Exit fullscreen mode

Reason:

  • Synchronous: 1, 4, 6
  • Microtasks: promise then (3) and async continuation (5)
  • Macrotask: timeout (2)

🧰 10) Real-world patterns you should know

✅ Run async tasks in parallel (faster)

Bad (sequential): waits for the first fetch before starting the second:

const a = await fetch(url1);
const b = await fetch(url2);
Enter fullscreen mode Exit fullscreen mode

Good (parallel): start both immediately, wait together:

const [res1, res2] = await Promise.all([
  fetch(url1),
  fetch(url2),
]);

const [data1, data2] = await Promise.all([
  res1.json(),
  res2.json(),
]);
Enter fullscreen mode Exit fullscreen mode

✅ Promise.allSettled (when you want all results, even failures)

const results = await Promise.allSettled([
  fetch(url1),
  fetch("bad-url"),
  fetch(url3),
]);

results.forEach((r, i) => {
  if (r.status === "fulfilled") console.log(i, "ok");
  else console.log(i, "failed:", r.reason);
});
Enter fullscreen mode Exit fullscreen mode

⚠️ 11) Common mistakes (and fixes)

Mistake 1: Using await outside async

const res = await fetch(url); // ❌ SyntaxError (in most contexts)
Enter fullscreen mode Exit fullscreen mode

Fix:

async function run() {
  const res = await fetch(url);
}
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Forgetting to handle errors

If you don’t catch errors, they become unhandled rejections and can crash Node apps or cause confusing browser console errors.

Fix:

try {
  const res = await fetch(url);
} catch (err) {
  console.log(err);
}
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Assuming fetch rejects on 404/500

Fetch resolves even for many HTTP errors.

Fix:

if (!res.ok) throw new Error(`HTTP ${res.status}`);
Enter fullscreen mode Exit fullscreen mode

Mistake 4: forEach + await doesn’t do what you think

items.forEach(async item => {
  await doWork(item); // ❌ doesn't wait the way you expect
});
Enter fullscreen mode Exit fullscreen mode

Fix (sequential):

for (const item of items) {
  await doWork(item);
}
Enter fullscreen mode Exit fullscreen mode

Fix (parallel):

await Promise.all(items.map(doWork));
Enter fullscreen mode Exit fullscreen mode

🧾 12) Quick cheat sheet

  • Promise: future value (resolve/reject)
  • .then / .catch: handle promise outcome
  • async: function returns a Promise
  • await: pause inside that async function until promise resolves
  • Event Loop: decides what runs next when stack is empty
  • Microtasks: promises/await continuations (run first)
  • Macrotasks: timers/events (run after microtasks)

🎯 Conclusion

Once you understand Promises, async/await, and the Event Loop, JavaScript becomes predictable instead of magical.

You’ll debug faster, write cleaner code, and avoid classic async bugs—especially in:

  • React / React Native
  • Node.js APIs
  • Any app that fetches data or handles events

If you remember only one thing:

await pauses only the async function. The event loop keeps JavaScript moving.


Bonus: Interview one-liner

“JavaScript uses the event loop to coordinate asynchronous operations. Promise callbacks and await continuations go into the microtask queue, which runs after the call stack is empty and before macrotask callbacks like setTimeout.”

Top comments (0)