JavaScript is single-threaded. The event loop, microtask queue, and macrotask queue determine when your async code actually runs. Understanding the order isn't optional — it's the difference between code that works and code that works by accident.
Pick a snippet and step through it. Watch items move between the call stack and the two queues. Microtasks always drain before the next macrotask runs.
Call Stack
Microtask Queue
Macrotask Queue
Output
Push console.log('1') onto call stack.
Three tasks start at the same time. The difference is when the combinator resolves. Promise.all waits for every task. Promise.race resolves at the first settlement. Promise.allSettled never short-circuits.
Promise.all
Promise.allSettled
Promise.race
Awaiting in a loop runs each request after the previous one finishes. Wrapping them in Promise.all fires them all at once. The code looks almost the same. The performance gap is 3×.
Sequential (awaiting in a loop)
for (const url of urls) { const res = await fetch(url) const data = await res.json() results.push(data) }
Parallel (Promise.all)
const results = await Promise.all( urls.map(async (url) => { const res = await fetch(url) return res.json() } ) )
Event loop quiz answer
console.log('1') setTimeout(() => console.log('2'), 0) Promise.resolve().then(() => console.log('3')) console.log('4') // Output: 1, 4, 3, 2 // sync first, then microtask, then macrotask
Promise.all usage
const [users, posts] = await Promise.all([ fetch('/api/users').then(r => r.json()), fetch('/api/posts').then(r => r.json()), ]) // both requests fly in parallel // rejects immediately if any fails
Error handling with try/catch
try { const data = await fetchData() render(data) } catch (err) { showError(err.message) } finally { hideSpinner() } // finally always runs — cleanup goes here
Microtasks before macrotasks, always