---
title: "14 — Asynchronous JavaScript — The Event Loop, Promises, and async/await"
uid: asynchronous-javascript
tags: ["event-loop", "roadmap:javascript", "closures", "timers", "async", "promises", "callbacks", "async-await", "javascript"]
excerpt: "JS is single-threaded with one call stack; async work is done by host APIs, and the event loop is the scheduler that puts finished-work callbacks back on the stack."
date: 2026-08-13T03:28:05+0000
source: https://www.aveshina.my.id/en/blog/asynchronous-javascript
---

"Fetch and setTimeout" was my async mental model, and it didn't explain the order in which things ran. The idea that everything else hangs off: **JavaScript is single-threaded with one call stack, and async work is done by the *host environment* (browser or Node); the event loop is just the scheduler that puts finished-work callbacks back onto the stack when it's empty.** [1]

The framing that finally landed is the *separation of labor*. The JS engine itself can only do one thing at a time — run the code on its single call stack. When I call setTimeout or fetch, the engine doesn't wait; it hands the actual waiting to the host environment and immediately moves on. When the host finishes (the timer fires, the response arrives), it pushes a callback into a queue. The event loop's one job is to watch the stack — when the stack is empty, it pulls the next callback off the queue and runs it. Async isn't a second thread; it's deferred scheduling on the same one thread [2][3].

```figure
<svg viewBox="0 0 740 320" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="The JavaScript runtime has four parts: a Call Stack (one column), a Heap, a Web APIs pool (timers, fetch, DOM) managed by the host, and a Callback Queue. The Event Loop arrow moves finished callbacks from the queue back onto the empty stack.">
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">
    <defs>
      <marker id="eloop" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
        <path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
      </marker>
    </defs>

    <!-- Call Stack -->
    <rect x="30" y="40" width="140" height="220" rx="8" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="100" y="62" font-size="12" font-weight="700" fill="#1e1b4b" text-anchor="middle">Call Stack</text>
    <text x="100" y="76" font-size="9" fill="#475569" text-anchor="middle">one thread</text>
    <rect x="45" y="200" width="110" height="22" rx="4" fill="#fff" stroke="#6366f1"/><text x="100" y="215" font-size="9.5" font-family="ui-monospace,monospace" fill="#1e1b4b" text-anchor="middle">main()</text>
    <rect x="45" y="172" width="110" height="22" rx="4" fill="#fff" stroke="#6366f1"/><text x="100" y="187" font-size="9.5" font-family="ui-monospace,monospace" fill="#1e1b4b" text-anchor="middle">console.log</text>

    <!-- Heap -->
    <rect x="30" y="270" width="140" height="36" rx="8" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1.5"/>
    <text x="100" y="293" font-size="11" font-weight="700" fill="#475569" text-anchor="middle">Heap (memory)</text>

    <!-- Web APIs -->
    <rect x="220" y="40" width="180" height="160" rx="8" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="310" y="62" font-size="12" font-weight="700" fill="#052e16" text-anchor="middle">Web APIs (host)</text>
    <text x="310" y="76" font-size="9" fill="#475569" text-anchor="middle">runs in parallel</text>
    <g font-size="9.5" font-family="ui-monospace,monospace" fill="#052e16" text-anchor="middle">
      <text x="310" y="105">setTimeout(timer, 1000)</text>
      <text x="310" y="130">fetch(url)</text>
      <text x="310" y="155">DOM events</text>
      <text x="310" y="180">readFile (Node)</text>
    </g>

    <!-- Callback Queue -->
    <rect x="450" y="40" width="140" height="220" rx="8" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="520" y="62" font-size="12" font-weight="700" fill="#422006" text-anchor="middle">Callback Queue</text>
    <text x="520" y="76" font-size="9" fill="#475569" text-anchor="middle">FIFO</text>
    <rect x="465" y="195" width="110" height="22" rx="4" fill="#fff" stroke="#ca8a04"/><text x="520" y="210" font-size="9.5" font-family="ui-monospace,monospace" fill="#422006" text-anchor="middle">cb1</text>
    <rect x="465" y="167" width="110" height="22" rx="4" fill="#fff" stroke="#ca8a04"/><text x="520" y="182" font-size="9.5" font-family="ui-monospace,monospace" fill="#422006" text-anchor="middle">cb2</text>

    <!-- Event Loop -->
    <rect x="620" y="120" width="110" height="80" rx="8" fill="#fce7f3" stroke="#db2777" stroke-width="1.5"/>
    <text x="675" y="150" font-size="12" font-weight="700" fill="#500724" text-anchor="middle">Event Loop</text>
    <text x="675" y="168" font-size="9" fill="#500724" text-anchor="middle">when stack empty,</text>
    <text x="675" y="182" font-size="9" fill="#500724" text-anchor="middle">drain queue → stack</text>

    <!-- arrows: stack -> web apis (handoff) -->
    <path d="M170,110 C195,110 200,110 218,110" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#eloop)"/>
    <text x="194" y="103" font-size="8.5" fill="#475569" text-anchor="middle">hand off</text>

    <!-- web apis -> queue (done) -->
    <path d="M400,150 C425,150 430,180 448,180" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#eloop)"/>
    <text x="424" y="160" font-size="8.5" fill="#475569" text-anchor="middle">done → enqueue</text>

    <!-- queue -> event loop -> stack -->
    <path d="M590,160 C605,160 610,160 618,160" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#eloop)"/>
    <path d="M620,200 C400,260 250,260 172,200" fill="none" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#eloop)"/>
    <text x="370" y="285" font-size="9" font-style="italic" fill="#64748b" text-anchor="middle">event loop ferries ready callbacks back to the empty stack</text>
  </g>
</svg>
```

## Closures and lexical scope: the prerequisite

Before async makes sense, two building blocks have to be solid, because every async pattern leans on them [4][5].

**Lexical scope** is scope determined by *where the function is written in the source*, not where it's called. A function defined inside another function has access to the outer function's variables — permanently, by the structure of the code.

**A closure** is the bundle of a function plus the lexical scope it was defined in. When I return an inner function, it carries the outer variables with it — they stay alive as long as the inner function exists. This is why a callback passed to setTimeout can still see the local variables of the function that registered it, *even after that function has returned*. The callback closes over them.

```
function makeLogger(msg) {
  return () => console.log(msg);  // closes over msg
}
const log = makeLogger("hi");
setTimeout(log, 1000);  // prints "hi" a second later — msg survived
```

Without closures, async callbacks couldn't remember the data they were created with. With them, the data rides along for free.

## Recursion: the other prerequisite

One more building block that the roadmap nests in here. **Recursion** is a function calling itself, with a **base case** that stops it [6]. Every recursion pushes a frame on the call stack; the base case pops them back down. It's not async, but it shares the stack-based way of thinking, and recursive data (trees, nested JSON) shows up constantly in async code (parsing a fetched response, walking a DOM). The discipline is the same: name the base case first, then the recursive step.

## Callbacks and callback hell

The earliest async mechanism is the **callback** — pass a function to be invoked later when the work finishes [7]. It works, but composing async steps produces nested, rightward-drifting code nicknamed **callback hell** or the pyramid of doom [8]:

```
getUser(id, (err, user) => {
  getPosts(user, (err, posts) => {
    getComments(posts[0], (err, comments) => {
      // three levels deep, error handling at each level
    });
  });
});
```

Each step depends on the previous, so they nest. Error handling is manual (the err first argument convention), and the control flow reads inside-out. Promises were invented specifically to flatten this.

## Promises: a value over time

A **Promise** is an object representing a value that may not exist yet — a placeholder for the eventual result of an async operation [9]. It's in one of three states: **pending** (waiting), **fulfilled** (succeeded with a value), or **rejected** (failed with a reason). Once settled, it never changes.

The win over callbacks is **chaining** — .then() returns a new promise, so steps line up flat instead of nesting:

```
getUser(id)
  .then(user => getPosts(user))
  .then(posts => getComments(posts[0]))
  .then(comments => render(comments))
  .catch(err => console.error(err));   // one catch handles any failure in the chain
```

Flat, top-to-bottom, one error path. .catch() at the end catches a rejection from *any* step, which is the error-handling cleanup callbacks never gave me. Promise.all (wait for all), Promise.race (first to settle), and Promise.allSettled (wait for all, regardless of outcome) compose multiple promises.

## async/await: promises that read like sync code

async/await is syntactic sugar over promises — same machinery, reads linearly [10]. An async function always returns a promise. Inside it, await pauses until a promise settles, then yields its value (or throws on rejection):

```
async function loadThread(id) {
  try {
    const user     = await getUser(id);
    const posts    = await getPosts(user);
    const comments = await getComments(posts[0]);
    return render(comments);
  } catch (err) {
    console.error(err);   // any await rejection lands here
  }
}
```

No nesting, no .then, and try/catch works the way I'd expect — a try around several awaits catches a rejection from any of them. This is the modern default; I reach for raw .then() chains only when composing promises without pausing (Promise.all([...])).

One subtlety worth keeping: await *pauses the function*, not the program. Other code can run while the function waits — the event loop keeps going. That's the whole point, and forgetting it is how people write accidentally-serial awaits where Promise.all would parallelize them.

## Timers: setTimeout and setInterval

Two host APIs for scheduling [11]:

- **setTimeout(fn, ms)** — run fn once after ms milliseconds. Returns an ID; clearTimeout(id) cancels.
- **setInterval(fn, ms)** — run fn every ms milliseconds until clearInterval(id).

Both push the callback onto the queue after the delay — they don't guarantee exact timing, only a *minimum* delay. If the stack is busy when the timer fires, the callback waits. For visual updates, requestAnimationFrame is the smoother alternative to setInterval.

## How I use this

The default for any async flow is **async/await with try/catch** — it reads like synchronous code and errors land naturally. When I have independent async operations, Promise.all([...]) runs them in parallel instead of await-ing serially (a common performance bug). I never write callback-style async in new code, and I treat the event loop as the answer to "why didn't this run when I expected" — almost always because the stack was busy, or the operation was queued behind other work. The way of thinking — one stack, host does the waiting, loop drains the queue — is what makes async behavior predictable instead of magical.

## References

[1] P. Lewis, "What the heck is the event loop anyway?," JSConf EU, 2014. [Video]. Available: [https://www.youtube.com/watch?v=8aGhZQkoFbQ](https://www.youtube.com/watch?v=8aGhZQkoFbQ)

[2] Node.js, "The Node.js event loop," 2024. [Online]. Available: [https://nodejs.org/learn/asynchronous-work/event-loop-timers-and-nexttick](https://nodejs.org/learn/asynchronous-work/event-loop-timers-and-nexttick)

[3] L. Hallie, "JavaScript Visualized: Event Loop," dev.to, 2019. [Online]. Available: [https://dev.to/lydiahallie/javascript-visualized-event-loop-3dif](https://dev.to/lydiahallie/javascript-visualized-event-loop-3dif)

[4] I. Kantor, "Closure," The Modern JavaScript Tutorial, 2024. [Online]. Available: [https://javascript.info/closure](https://javascript.info/closure)

[5] Mozilla, "Closures — lexical scoping," MDN Web Docs, 2024. [Online]. Available: [https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures)

[6] I. Kantor, "Recursion and stack," The Modern JavaScript Tutorial, 2024. [Online]. Available: [https://javascript.info/recursion](https://javascript.info/recursion)

[7] I. Kantor, "Callbacks," The Modern JavaScript Tutorial, 2024. [Online]. Available: [https://javascript.info/callbacks](https://javascript.info/callbacks)

[8] I. Kantor, "Promise chaining," The Modern JavaScript Tutorial, 2024. [Online]. Available: [https://javascript.info/promise-chaining](https://javascript.info/promise-chaining)

[9] Mozilla, "Using promises," MDN Web Docs, 2024. [Online]. Available: [https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises)

[10] I. Kantor, "Async/await," The Modern JavaScript Tutorial, 2024. [Online]. Available: [https://javascript.info/async-await](https://javascript.info/async-await)

[11] I. Kantor, "Scheduling: setTimeout and setInterval," The Modern JavaScript Tutorial, 2024. [Online]. Available: [https://javascript.info/settimeout-setinterval](https://javascript.info/settimeout-setinterval)

```quiz
Q: Where does the actual waiting happen for setTimeout and fetch?
- On the JS engine's single call stack
- In the host environment (browser/Node Web APIs); the engine hands it off and moves on
correct: 1
explain: The engine itself is single-threaded and does no waiting. setTimeout/fetch hand the work to host APIs that run in parallel; when done, they enqueue a callback. The event loop drains the queue onto the empty stack.

Q: What does the event loop do?
- It runs a second thread of JavaScript
- When the call stack is empty, it pulls the next callback from the queue and runs it on the stack
correct: 1
explain: The event loop is a scheduler, not a worker. Its single job: watch the stack, and when it's empty, move a queued callback onto it. That's how async results re-enter the program.

Q: A closure is…
- a function bundled with the lexical scope it was defined in, keeping those variables alive even after the outer function returns
- a function that runs synchronously
correct: 0
explain: Closures let an inner function remember the variables of its enclosing function. This is why an async callback (passed to setTimeout, returned from a request) can still see the data of the function that created it, after that function has returned.

Q: Why did promises replace the callback pyramid?
- They chain flatly via .then (each .then returns a new promise) and route any rejection to a single .catch
- They run on a separate thread
correct: 0
explain: Each .then returns a promise, so dependent steps line up top-to-bottom instead of nesting. A single .catch handles a rejection from any step. Same single-threaded model, just cleaner composition.

Q: Inside an async function, `await`…
- pauses only that function until the promise settles; the rest of the program keeps running via the event loop
- pauses the entire program, blocking all other code
correct: 0
explain: await suspends the async function's execution and yields control back to the event loop. Other code runs while it waits. Forgetting this leads to accidentally-serial awaits where Promise.all would parallelize them.
```
