14 — Asynchronous JavaScript — The Event Loop, Promises, and async/await
"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].
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 survivedWithout 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 chainFlat, 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!