AV
HomeAboutProjectBlog

© 2026 Ave syah Shina. All rights reserved.

  1. Home
  2. Blog
  3. 14 — Asynchronous JavaScript — The Event Loop, Promises, and async/await

14 — Asynchronous JavaScript — The Event Loop, Promises, and async/await

August 13, 20268 min read
Download as Markdown

"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].

Call Stack one thread main() console.log Heap (memory) Web APIs (host) runs in parallel setTimeout(timer, 1000) fetch(url) DOM events readFile (Node) Callback Queue FIFO cb1 cb2 Event Loop when stack empty, drain queue → stack hand off done → enqueue event loop ferries ready callbacks back to the empty stack

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

[2] Node.js, "The Node.js event loop," 2024. [Online]. Available: 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

[4] I. Kantor, "Closure," The Modern JavaScript Tutorial, 2024. [Online]. Available: 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

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

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

[8] I. Kantor, "Promise chaining," The Modern JavaScript Tutorial, 2024. [Online]. Available: 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

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

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

Knowledge check · Question 1 of 5

Where does the actual waiting happen for setTimeout and fetch?

Comments

Leave a Comment

You must be signed in to comment

0 Comments

No comments yet. Be the first to comment!