Free Handbook · Runs in your browser

Async JavaScript

The event loop traced so setTimeout(0) finally makes sense, callbacks and why they nest, promises with then/catch/finally, async/await as the way you actually write it, fetch and JSON, running things in parallel with Promise.all, and the two async errors — forgotten await and unhandled rejection — everyone hits.

0 / 111 lessons🔥 0 day streak
ShareXLinkedIn

Module 06 · what you'll be able to do

  • Explain why JavaScript is single-threaded and how the event loop lets it wait without blocking
  • Predict the order of sync code, microtasks and timers
  • Chain promises and convert callbacks to promises
  • Write async/await with correct error handling
  • Fetch JSON from an API and run requests in parallel or in sequence on purpose
01

The event loop

JavaScript runs on one thread: one thing at a time, and while it is running nothing else can — not a click, not a timer. So anything slow (a network request, a file read, a timer) is handed to the host, and JavaScript moves on. When the slow thing finishes, its callback is put in a queue; the event loop runs the next queued callback whenever the stack is empty. Two queues matter: microtasks (promise callbacks — run first, all of them) and macrotasks (timers, I/O — one per turn).

javascriptEdit it. ⌘/Ctrl + Enter runs.
You should see
1: sync start
2: sync end
2b: after blocking
3: microtask
4: another microtask
5: timeout 0ms
6: timeout 10ms
Your turn
Put a Promise.resolve().then(...) inside the first setTimeout callback. Where does it print? (Microtasks drain after every macrotask.)
VisualizeSync → microtasks → macrotasksStep 1 / 6
console.log("A")
setTimeout(() => console.log("D"), 0)
Promise.resolve().then(() => console.log("C"))
console.log("B")
Line 1

Runs immediately on the stack.

Variables now
stackmain
microtasks[]
macrotasks[]
Printed so far
A
All 6 steps as a table
StepLineWhat happenedVariables now
11Runs immediately on the stack.stack = main microtasks = [] macrotasks = []
22Hand the timer to the host. Its callback will be queued as a macrotask after ≥0 ms — not run now.macrotasks = [D] (pending)
33The promise is already resolved, so its .then callback is queued as a microtask — still not run.microtasks = [C]
44Still on the main stack.
54Main script finishes; the stack is empty. The event loop drains ALL microtasks first.stack = (empty) microtasks = []
62Then one macrotask: the timer callback.macrotasks = []
Why this matters
It is why setTimeout(fn, 0) means "after everything else currently queued", why a long loop freezes the page, why await lets other work happen, and why Node can serve thousands of connections on one thread — it never waits, it queues.
02

Callbacks and callback hell

The original async tool: pass a function to be called when the work is done. Timers, event listeners and old Node APIs all use it. It works — until you need to do three things in sequence, each depending on the last, and the code drifts to the right. Promises exist to fix exactly that.

javascriptEdit it. ⌘/Ctrl + Enter runs.
You should see
requests sent
failed: bad id
user1 spent 70
Two callback rules
Error-first: Node callbacks are (err, result); check err before touching result. Never mix — a function that sometimes calls back synchronously and sometimes async is a bug factory. Promises enforce both rules for you.
03

Promises

A promise is an object standing in for a value that is not ready yet. It is pending, then either fulfilled with a value or rejected with an error — exactly once. You attach what should happen next with .then, catch failures with .catch, and clean up with .finally. Because .then returns a new promise, steps chain flat instead of nesting.

javascriptEdit it. ⌘/Ctrl + Enter runs.
You should see
sync code first
first handler 42
second handler 42
caught boom
failed: bad id
done (-1)
got user1
spent 70
done (1)
Your turn
Write timeout(promise, ms) that rejects if the promise takes longer than ms. Hint: Promise.race with a sleep that rejects.
Pending / fulfilled / rejected
The three states. "Settled" means fulfilled or rejected. A promise never changes state twice.
Thenable
Anything with a .then method. await and .then unwrap it, so returning a promise from a then flattens instead of nesting.
Promise.resolve(x)
A promise already fulfilled with x. Handy for making a sync value behave like an async one.
04

async / await

async/await is syntax over promises that lets you write asynchronous code in the order it happens. An async function always returns a promise; inside it, await pauses that function (not the whole program) until a promise settles, then gives you its value — or throws its error, which means ordinary try/catch works. This is how nearly all modern JavaScript is written.

javascriptEdit it. ⌘/Ctrl + Enter runs.
You should see
sync first
cleanup for -1
failed: bad id
cleanup for 1
user1 spent 70
parallel: ~40ms
sequential: ~80ms
Your turn
Write retry(fn, times) — an async function that calls fn up to times times, awaiting each, returning the first success or throwing the last error.
Error you will hit

Forgot await: Promise { <pending> }

javascript
const getUser = async () => ({ name: "Ada" })

async function main() {
  const user = getUser()          // missing await
  console.log(user.name)
}
main()
undefined
Why the engine said that

Without await, user is the promise, not the object. A promise has no name, so you get undefined — or, if you then call a method on it, "x is not a function". Logging the variable shows Promise { <pending> }, which is the tell.

The fix

Await it. And remember an async function's caller needs to await too — async is contagious up the call chain.

javascript
const getUser = async () => ({ name: "Ada" })

async function main() {
  const user = await getUser()
  console.log(user.name)   // Ada
}
main()
Error you will hit

Unhandled promise rejection

javascript
async function save() {
  throw new Error("disk full")
}
save()               // nobody awaits or catches
console.log("continuing")
continuing
Uncaught (in promise) Error: disk full
    at save (your code:2)
Why the engine said that

The rejected promise was dropped on the floor. Browsers log it; Node 15+ crashes the process. This is the async version of forgetting try/catch, and it is silent until it is not.

The fix

Every promise needs an owner: await it inside a try/catch, or attach .catch. For fire-and-forget work, catch and log explicitly: save().catch(log).

javascript
async function save() {
  throw new Error("disk full")
}
save().catch(err => console.log("save failed:", err.message))
console.log("continuing")
05

fetch and JSON APIs

fetch(url) returns a promise of a Response; response.json() returns a promise of the parsed body. Two awaits. The trap everyone falls into: fetch only rejects on network failure — a 404 or 500 is a successful fetch with response.ok === false, so you must check it yourself. There is no network in this sandbox, so this lesson is code to paste into a browser console or Node.

javascriptapi.js
// GET JSON, with the checks a real app needs
async function getJson(url, { timeoutMs = 8000 } = {}) {
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), timeoutMs)
  try {
    const res = await fetch(url, { signal: controller.signal })
    if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`)
    return await res.json()
  } finally {
    clearTimeout(timer)
  }
}

// POST JSON
async function postJson(url, body) {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  })
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json()
}

// Using them
const repo = await getJson("https://api.github.com/repos/nodejs/node")
console.log(repo.stargazers_count)

// Parallel requests — start all, then await all
const names = ["nodejs/node", "denoland/deno", "oven-sh/bun"]
const repos = await Promise.all(names.map(n => getJson(`https://api.github.com/repos/${n}`)))
console.log(repos.map(r => [r.name, r.stargazers_count]))

// Tolerate individual failures
const results = await Promise.allSettled(names.map(n => getJson(`https://api.github.com/repos/${n}`)))
for (const r of results) console.log(r.status, r.status === "fulfilled" ? r.value.name : r.reason.message)

Never await in a loop when the requests are independent — start them all and Promise.all. Do await in a loop when each request depends on the previous or you must rate-limit.

javascriptEdit it. ⌘/Ctrl + Enter runs.
You should see
{ path: '/users', data: 6 }
HTTP 404 for /missing
[ 2, 3, 4 ]
[ 'fulfilled', 'rejected' ]
race winner has data 9
any: /y
CombinatorResolves whenRejects whenUse for
Promise.allall fulfil → array of valuesany rejects (fast-fail)independent requests that must all succeed
Promise.allSettledall settle → array of {status, value/reason}neverpartial results are fine
Promise.racefirst settles (either way)first rejectstimeouts
Promise.anyfirst fulfilsall reject (AggregateError)fastest mirror wins
06

Timers, intervals and event emitters

javascriptEdit it. ⌘/Ctrl + Enter runs.
You should see
saved 42
also notified
tick 1
tick 2
tick 3
searching for abc
after everything
Your turn
Write throttle(fn, ms): run at most once per ms, ignoring calls in between. Compare with debounce — when would you use each?
Mid-levelWhat will this print, and why? console.log(1); setTimeout(() => console.log(2)); Promise.resolve().then(() => console.log(3)); console.log(4)

1, 4, 3, 2. Synchronous code runs first (1, 4). When the stack empties, the microtask queue is drained — the promise callback (3). Only then does the event loop take a macrotask — the timer (2), even with a 0 ms delay.

What they are really testing: The event loop model. A follow-up is usually "what if the promise callback itself queues a timeout?".

SeniorNode is single-threaded. How does it handle 10,000 concurrent connections, and when does that model break down?

Node never blocks on I/O: each request registers callbacks and returns to the event loop, so a single thread multiplexes thousands of mostly-waiting connections, with libuv's thread pool doing file and DNS work. It breaks down under CPU-bound work — image processing, big JSON parsing, crypto — because one long task starves every other request. The fixes are worker threads, a job queue, or moving that work to a separate service. Measuring event-loop lag is how you detect it.

Finish the JavaScript handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.