JavaScript Event Loop & Async Programming: Promise & async/await from Theory to Practice
This article explains JavaScript's single-threaded event loop model, detailing microtask and macrotask queues, Promise chaining, async/await mechanics, common pitfalls, and practical concurrency control patterns with code examples. It covers classic output prediction exercises, Promise.all/race/allSettled/any usage, async/await error handling, loop await traps, and a reusable limited-concurrency pool implementation for batch requests.
Single-Threaded Event Loop Model
JavaScript is single-threaded to avoid lock complexity when multiple threads manipulate the DOM. It uses single thread + async scheduling :
JS main thread: call stack, executes synchronous code line by line
↓ When stack empties? Check queues
Event Loop:
1. Call stack empty
2. Drain microtask queue (run all)
3. Take one macrotask onto stack
4. Return to step 2Microtasks : Promise.then/catch/finally, queueMicrotask, MutationObserver. Macrotasks : setTimeout/setInterval, setImmediate (Node), I/O callbacks, UI render events.
After each macrotask finishes, the microtask queue is completely drained before the next macrotask runs.
Microtasks effectively "cut in line" — they must finish before any subsequent macrotask.
Classic Output Exercises
Basic Example
console.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => {
console.log('3')
}).then(() => {
console.log('4')
})
console.log('5')Execution order:
1. Synchronous code prints 1, 5.
2. setTimeout is a macrotask, queued.
3. Promise.then callbacks are microtasks, queued.
4. After sync code, microtasks run: 3, then chained 4.
5. Finally macrotask runs: 2. Output: 1 → 5 → 3 → 4 → 2 Key pitfall : setTimeout(fn, 0) does not run immediately; it runs after current macrotask and all queued microtasks complete.
Async/Await Variant
async function a() {
console.log('a-start')
await b() // code after await becomes a microtask
console.log('a-end')
}
async function b() { console.log('b') }
console.log('script-start')
a()
setTimeout(() => console.log('timeout'), 0)
Promise.resolve().then(() => console.log('promise'))
console.log('script-end') Output: script-start → a-start → b → script-end → a-end → promise → timeoutReason : await wraps following code into a microtask, yielding the main thread to run remaining synchronous code.
Promise Essentials
Three States & Chaining
pending ──resolve()──▶ fulfilled
↑ .then chain passes returned values forward
└-----─reject()──▶ failed ──.catch catches fetchUser()
.then(user => fetchOrders(user.id)) // returns new Promise, chain waits
.then(orders => render(orders))
.catch(err => showError(err)) // catches any prior error
.finally(() => hideLoading()) // runs regardlessCore rules :
1. .then returning a plain value passes that value to next .then.
2. .then returning a Promise pauses chain until that Promise settles.
3. State is immutable once settled; calling resolve twice only honors first.
4. .catch can be followed by .then — error handled, chain resumes as fulfilled.
Three Combinators: Serial / Parallel / Race
// Serial: one after another (total time = sum)
for (const id of ids) { const user = await getUser(id) }
// Parallel: all start together, wait for all (time = slowest)
const [user, orders, coupons] = await Promise.all([
getUser(id), getOrders(id), getCoupons(id)
])
// Any failure rejects entire Promise.all
// All settled (collect success/failure details):
const res = await Promise.allSettled([getUser(1), getUser(2)])
res[0].status === 'fulfilled' ? res[0].value : res[0].reason
// Race: first to settle (used for timeout)
const data = await Promise.race([
fetchWithTimeout(url),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 5000))
])
// Any: first to fulfill (multiple CDN fallback)
const fastest = await Promise.any([cdn1Fetch(url), cdn2Fetch(url)])Promise.all : all must succeed; one failure rejects all.
Promise.allSettled : waits for all, never rejects; inspect each result.
Promise.race : first settlement (success or failure) wins.
Promise.any : first fulfillment wins; rejects only if all fail.
Async/Await Practical Points
It's Syntactic Sugar
// Equivalent
async function load() {
const user = await getUser()
return user
}
function load() {
return getUser().then(user => user)
}Pitfall : async functions always return a Promise — return 1 actually returns Promise<1>; caller must await or .then to get 1.
Await in Loops
// ❌ map with async callback returns Promise[]
const names = users.map(async u => (await getDetail(u.id)).name)
// names is Promise[] not string[]!
// ✅ Fix with Promise.all
const names = await Promise.all(
users.map(u => getDetail(u.id).then(d => d.name))
)Pitfall : forEach ignores returned Promises; loop finishes before async callbacks complete.
Rule: use for...of for serial; use map + Promise.all for parallel.
Error Handling Patterns
// Pattern 1: local try-catch (fine-grained)
try { const user = await getUser() } catch (e) { return null }
// Pattern 2: top-level catch (concise but loses which step failed)
const run = async () => { /* many awaits */ }
run().catch(console.error)Real-World: Concurrency-Limited Batch Requests
Business need: upload 200 files / fetch 1000 details — cannot Promise.all all at once (browser limits, backend overload), nor pure serial (too slow). Solution: concurrency-limited pool .
async function runWithLimit(tasks, limit = 5) {
const results = []
const executing = new Set()
for (const task of tasks) {
if (executing.size >= limit) {
await Promise.race(executing) // wait for any to finish
}
const p = Promise.resolve().then(() => task())
executing.add(p)
p.finally(() => executing.delete(p))
.then(r => results.push(r))
}
await Promise.all(executing) // wait remaining
return results
}
// 200 requests, max 5 concurrent
const details = await runWithLimit(ids.map(id => () => fetchDetail(id)), 5)Enhanced version with progress callback and order preservation:
async function runWithProgress(tasks, { limit = 5, onProgress } = {}) {
let done = 0
const results = []
const executing = new Set()
for (const [index, task] of tasks.entries()) {
if (executing.size >= limit) await Promise.race(executing)
const p = Promise.resolve()
.then(() => task())
.then(r => {
results[index] = r // store by original index to keep order
done++
onProgress?.(done, tasks.length)
})
executing.add(p)
p.finally(() => executing.delete(p))
}
await Promise.all(executing)
return results
}Quick Reference Cheatsheet
Serial wait : for...of + await Parallel all : Promise.all Parallel fault-tolerant : Promise.allSettled Timeout control : Promise.race([request, timeoutPromise]) Multi-source race : Promise.any Limit concurrency : pool pattern + Promise.race for slot replenishment
Microtasks : .then callbacks, queueMicrotask Macrotasks : setTimeout, I/O, event callbacks
Takeaway : The event loop is the dividing line between superficial and deep JavaScript understanding. Internalize the two-phase model and "macrotasks must wait for microtask queue to empty" — then output-order questions become trivial.
Further reading: high-frequency events (scroll/resize/input) from the event loop; see companion article on debounce and throttle.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Code Farmer Manor Chronicle
A heart like drifting clouds, ever at ease; a mind like flowing water, free to roam.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
