Frontend Master

JavaScript Curriculum

Waiting Without Blocking
+55 XP

Waiting Without Blocking

hard
~28 min·55 XP

Nexus needs to load a user profile from an API, then fetch their recent activity, then render the dashboard. Each step depends on the previous one. Each step takes time — the network is involved. You can't freeze the browser while you wait. JavaScript has a specific, elegant model for handling exactly this — and understanding it changes how you think about code.

JavaScript is single-threaded — but not single-tasked

JavaScript runs on a single thread. That means it can only do one thing at a time. If a synchronous operation takes 3 seconds — reading a file, waiting for a server, crunching a big dataset — the entire program freezes for 3 seconds. The browser can't respond to clicks. The UI locks up.

The browser and Node.js solve this with an architecture called the event loop. Long-running operations are offloaded to platform APIs (the browser's networking layer, the OS file system). JavaScript keeps running. When the operation completes, a callback is placed in a queue. The event loop moves it onto the call stack when the stack is clear.

Watch the full cycle with setTimeout:

event loop — watch the non-blocking model
console.log("Before timeout")
setTimeout(() => console.log("Inside timeout callback"), 0)
console.log("After timeout call")

call stack

empty

web apis

idle

task queue

empty

Code not yet running.

console output

nothing yet…

🧠The key insight
setTimeout(fn, 0) doesn't mean "run immediately." It means "run as soon as the call stack is empty." Synchronous code always runs to completion first. Only then does the event loop move queued callbacks onto the stack. This is why "After timeout call" prints before "Inside timeout callback" — even with a 0ms delay.

The three eras of async JavaScript

The same problem — fetch data, do something with it — has been solved three different ways as the language evolved. Understanding all three makes you a better debugger and a better code reader.

Era 1: Callbacks (pre-2015)

Pass a function to be called when the async work is done. Simple in concept, destructive at scale:

js
fetchUser(42, function(err, user) { if (err) { console.error(err); return } fetchPosts(user.id, function(err, posts) { if (err) { console.error(err); return } fetchComments(posts[0].id, function(err, comments) { if (err) { console.error(err); return } // three levels deep — and this is only three operations console.log(comments) }) }) })

This is "callback hell" — each level of nesting pushes the logic rightward and duplicates error handling. Real codebases had six or seven levels. Code became impossible to follow.

Era 2: Promises (ES6, 2015)

A Promise is an object representing an operation that hasn't completed yet. It can be in one of three states — and it can only ever transition from pending to one of the settled states, never backwards:

promise state — watch the transition

Promise

pending
pending
fulfilled
/
rejected
fetchUser(42)
.then(user => console.log(user))
.catch(err => console.error(err.message))

.then() registers a handler for the resolved value. .catch() registers a handler for the rejection. They chain — each .then() can return a new Promise, and the chain continues:

js
fetchUser(42) .then(user => fetchPosts(user.id)) // returns a Promise .then(posts => fetchComments(posts[0].id)) // chains off it .then(comments => console.log(comments)) .catch(err => console.error(err)) // catches any failure in the chain

One .catch() at the end handles errors from any stage. Flat, readable, far better.

Era 3: async/await (ES8, 2017)

async/await is syntactic sugar over Promises — it doesn't replace them, it wraps them in a syntax that reads like synchronous code. An async function always returns a Promise. await pauses execution of the async function until the awaited Promise settles — without blocking the thread.

ES5 era

The original async pattern. Pass a function to be called when the operation completes. Works, but nests badly — "callback hell" when multiple async operations chain together.

fetchUser(42, function (err, user) {
if (err) {
console.error(err); return
}
fetchPosts(user.id, function (err, posts) {
if (err) {
console.error(err); return
}
console.log(posts)
})
})

✗ Deep nesting, error handling repeated at every level, hard to reason about.

All four versions perform exactly the same operations — fetch user, then fetch posts, then log them. The difference is entirely in readability and error handling ergonomics.

js
// This is the async/await mental model: async function loadData() { const user = await fetchUser(42) // pauses HERE until resolved const posts = await fetchPosts(user.id) // then pauses HERE return posts // wraps result in a resolved Promise } // Calling an async function always gives you a Promise back loadData().then(posts => console.log(posts)) // or, inside another async function: const posts = await loadData()

The await keyword can only be used inside an async function. You cannot await at the top level of a module unless you're using ES2022 top-level await.


Error handling in async/await

Without try/catch, a rejected Promise will produce an unhandled rejection warning (and in Node.js, will crash the process). Always wrap await calls in try/catch:

js
async function getUserDashboard(userId) { try { const user = await fetchUser(userId) const activity = await fetchActivity(userId) return { user, activity } } catch (err) { console.error("Dashboard load failed:", err.message) return null } finally { setLoading(false) // always runs — hides spinner whether we succeeded or not } }
⚠️async functions never throw — they reject
If an async function throws (or an awaited Promise rejects), the async function's own Promise is rejected. If the caller doesn't await it inside a try/catch, the error is silently swallowed. Every async function call either needs to be awaited in a try/catch, or have .catch() attached to the returned Promise.

Running operations in parallel

await is sequential by default — the second operation doesn't start until the first finishes. When two operations don't depend on each other, run them in parallel with Promise.all:

js
// Sequential — 2s + 2s = ~4s total const user = await fetchUser(42) const settings = await fetchSettings(42) // Parallel — both start at once, ~2s total const [user, settings] = await Promise.all([ fetchUser(42), fetchSettings(42), ])

Promise.all takes an array of Promises and returns a single Promise that resolves when all of them resolve (with an array of their values), or rejects as soon as any one rejects.

js
// Promise.all rejects fast — one failure cancels the whole group try { const [user, posts, settings] = await Promise.all([ fetchUser(42), fetchPosts(42), fetchSettings(42), ]) renderDashboard(user, posts, settings) } catch (err) { // if any one of the three failed console.error("Dashboard load failed:", err.message) }

The modern fetch pattern

fetch() — the browser's built-in HTTP function — returns a Promise. This is the pattern you'll write constantly in frontend JavaScript:

js
async function getUser(id) { try { const response = await fetch(`https://api.nexus.app/users/${id}`) // fetch doesn't throw on 4xx/5xx — you must check manually if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } const user = await response.json() // also returns a Promise return user } catch (err) { console.error("getUser failed:", err.message) return null } }
💡fetch never throws on HTTP errors
fetch only rejects on network failure (no connection, DNS failure). A 404 or 500 response resolves normally. You must check response.ok or response.status yourself and throw if the request wasn't successful. This surprises almost everyone the first time.

Your challenge

Wire up getUserDashboard using the two provided async helper functions. The structure is exactly the production pattern: await both operations, combine the results into an object, wrap everything in try/catch, return null on failure.

fetchUser and fetchActivity are provided — they simulate API calls with realistic delays. Your job is the orchestration layer around them.

Challenge

Write an async function called getUserDashboard(userId) that: (1) awaits a call to fetchUser(userId) — a provided async function that resolves to a user object, (2) awaits a call to fetchActivity(userId) — a provided async function that resolves to an array of activity strings, (3) returns an object with user and activity properties. Wrap everything in try/catch — on error, log the message and return null.

asyncpromisesasync-awaitevent-loopcallbacksfetchnon-blockingconcurrency
Waiting Without Blocking | Nexus Learn