Asynchronous JavaScript and the Event Loop
Develop a deep understanding of promises, microtasks, macrotasks, timers, async/await, event-loop ordering, and concurrency.
Synchronous vs Asynchronous Work
Synchronous code executes as part of the current call stack. Asynchronous APIs allow work to be initiated without forcing the current JavaScript execution to wait for completion. The actual mechanism depends on the host environment.
Asynchrony does not mean JavaScript suddenly executes multiple JavaScript statements simultaneously on the same agent. Instead, completed asynchronous operations schedule callbacks or promise reactions to be processed later.
- Asynchronous APIs prevent the current operation from waiting unnecessarily.
- Completion callbacks run later.
- Asynchrony and parallel JavaScript execution are different concepts.
Does an asynchronous API necessarily mean JavaScript executes two pieces of JS simultaneously on one event-loop agent?
Promises as State Machines
A Promise represents the eventual result of an asynchronous operation. It starts pending and transitions to either fulfilled or rejected. Once settled, its state does not change again.
Calling then, catch, or finally creates promise reactions that are scheduled asynchronously. A promise does not itself perform background work; it represents and coordinates eventual completion.
- A Promise can be pending, fulfilled, or rejected.
- Settled promises cannot transition again.
- Promises represent eventual outcomes and chain asynchronous operations.
Which transition is valid for a Promise?
Microtasks vs Tasks
Promise reactions and queueMicrotask() use the microtask queue. Timers and many host callbacks are scheduled as tasks. After the current JavaScript job completes, the runtime processes available microtasks before moving on to another task.
This is why a resolved Promise can run before a zero-delay timer.
console.log('A');
setTimeout(() => {
console.log('B');
}, 0);
Promise.resolve().then(() => {
console.log('C');
});
console.log('D');- Synchronous code runs before queued asynchronous callbacks.
- Promise reactions are microtasks.
- Microtasks are processed before the runtime proceeds to another task.
What is the output order of the example?
async/await Is Promise Syntax
An async function always returns a Promise. await pauses the execution of that async function until the awaited Promise settles, but it does not block the JavaScript thread. The rest of the async function becomes a continuation that resumes later.
This distinction is critical: await makes asynchronous code easier to read without turning asynchronous operations into synchronous blocking operations.
async function loadUser() {
const response = await fetch('/api/user');
const user = await response.json();
return user;
}
loadUser().then(console.log);- Every async function returns a Promise.
awaitpauses the async function, not the entire JavaScript runtime.- Async/await is built around Promise semantics.
What does an async function always return?
Promise.all, allSettled, race, and any
Promise.all waits for every input to fulfill and rejects as soon as one rejects. Promise.allSettled waits for every input and reports each outcome. Promise.race settles when the first input settles. Promise.any fulfills when the first input fulfills and rejects only when every input rejects.
Choosing the correct combinator is about business semantics, not just syntax.
const [user, posts] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json())
]);
console.log(user, posts);- Promise.all is appropriate when every operation must succeed.
- Promise.allSettled is useful when every result matters regardless of success.
- Promise.any is useful when any successful source is sufficient.
Which Promise combinator waits for all inputs regardless of whether they fulfill or reject?
A Deeper Event Loop Mental Model
The event loop should be understood as coordination between execution jobs and host scheduling mechanisms rather than as one universal queue. Browsers and Node.js have different host-level details, but the common model is that JavaScript executes jobs, asynchronous operations complete in the host, and continuations become eligible for later execution.
A long-running synchronous task prevents queued work from running. Likewise, repeatedly scheduling microtasks can delay tasks because the runtime must process the microtask queue before moving to another task.
let count = 0;
function loop() {
count++;
if (count < 100000) {
queueMicrotask(loop);
}
}
setTimeout(() => console.log('timer'), 0);
loop();- The event loop coordinates JavaScript jobs with host scheduling.
- Long synchronous work blocks subsequent JavaScript execution.
- Excessive microtasks can delay task execution.
Why can a long chain of microtasks delay a timer?