Synchronous code runs one line at a time: finish the first, then the next, then the next. That is easy to follow, and it becomes a real defect the moment a single operation has to wait. Suppose your code asks a database for a record and the reply takes half a second. On a single thread that half second freezes everything behind it, so no click registers, no animation steps, and the whole page looks hung.
JavaScript runs on one thread, which means any statement that blocks blocks the entire program. The way out is asynchronous code: start a slow job, keep doing other useful work while it runs, and handle the answer when it arrives. This lesson introduces the machinery that makes that possible, the event loop, and the original tool for driving it, the callback.
flowchart TD A["synchronous code runs on the call stack"] --> B["a slow job is handed to an async API"] B --> C["JavaScript keeps running the lines after it"] C --> D["the finished task callback enters a queue"] D --> E["the event loop runs it once the stack is clear"] style A fill:#3776ab,color:#fff style D fill:#b45309,color:#fff
What async does, and does not, fix
It is worth being precise about the slowness async actually addresses. Waiting on a reply from a database, a file, or the network is called I/O, and during that wait the thread is simply idle, doing nothing, which is the ideal gap for async code to fill with other work. A genuinely heavy calculation is different: it truly occupies the thread, and making it async will not make it run faster. Asynchronous programming is about waiting well, not about computing more quickly.
One thread, but never idle
The call stack is how JavaScript remembers where it is. The function currently running sits on top of the stack; when it finishes, control drops back to whoever called it. Everything that sits on the stack is the synchronous code executing right now.
Async work is kept off the stack. When you pass a job to an async API such as setTimeout, the engine records it and immediately continues your function instead of standing still. When the job completes, the function you wanted to run afterward is placed in a queue, not executed at once. So the thread is single, but it is rarely idle: while one job waits, the engine is free to run other code, which is exactly why an interface stays responsive during slow work.
console.log("1: start");
setTimeout(() => {
console.log("3: timeout fired");
}, 0);
console.log("2: end of script");
Two queues, one important rule
Finished async work lands in one of two queues. Macrotasks, such as a setTimeout callback or a DOM event handler, wait in the task queue. Microtasks, such as the callback of a resolved Promise, wait in a separate, faster queue. Both are drained only when the call stack is empty.
The rule that explains every surprising print order is short: between any two macrotasks, the engine clears the entire microtask queue first. That is why a resolved Promise runs before a setTimeout set to zero, even though both are already done. Hold onto that rule; the quiz leans on it, and it underpins everything in the next two lessons.
flowchart LR S["call stack empty"] --> M["drain ALL microtasks<br/>(resolved Promises)"] M --> T["run ONE macrotask<br/>(timeout, event)"] T --> S style M fill:#1e7f3d,color:#fff style T fill:#b45309,color:#fff
Callbacks: 'call me back when you are done'
The oldest way to receive an async result is a callback: a function you pass to another function, to be invoked later with the answer. You have already used callbacks under another name, because map, filter, and forEach each accept a function they call once per element.
The same pattern covers genuinely async work. You call a function and hand it a second function; when the slow job finishes, that function invokes your callback and passes it the result. The mental shift is about control: you do not wait for the answer, you arrange to be told. Nothing in the language forces the caller to actually invoke your callback, to invoke it exactly once, or to hand it the right arguments, which is why strong conventions grew up around them.
function loadUser(id, cb) {
// pretend this takes time; here it answers at once
cb({ id: id, name: "Ada", role: "admin" });
}
loadUser(7, (user) => {
console.log("loaded " + user.name + " as " + user.role);
});
// the callback can do anything a normal function can
loadUser(7, (user) => {
console.log(user.id + ": " + user.name.toUpperCase());
});
The error-first convention
Because a callback has no built-in way to signal failure, the Node.js community settled on the error-first style: the callback's first parameter is reserved for an error and is null when nothing went wrong, while the real result comes second. Writing if (err) as the first line of every callback became muscle memory.
A single callback is manageable. Trouble arrives when the second async step needs the first step's result, so you nest the second call inside the first callback, the third inside the second, and the shape curls into a rightward triangle nicknamed callback hell. Each level wants its own error check, so three steps mean three if (err) guards, and skipping one lets a failure vanish without a trace. That pain is exactly what Promises were invented to remove.
flowchart LR A["loadUser(7, onDone)"] --> B["loadUser does its work"] B --> C["onDone(null, user)"] B --> D["onDone(error)"] style C fill:#1e7f3d,color:#fff style D fill:#b91c1c,color:#fff
function findUser(id, cb) {
if (id === 99) {
cb(new Error("user 99 not found"));
} else {
cb(null, { id: id, name: "Ada" });
}
}
findUser(7, (err, user) => {
if (err) {
console.log("error: " + err.message);
} else {
console.log("ok: " + user.name);
}
});
findUser(99, (err, user) => {
if (err) {
console.log("error: " + err.message);
} else {
console.log("ok: " + user.name);
}
});
What does this print? The callback is invoked immediately, so trace what value it receives.
function twice(n, cb) {
cb(n * 2);
}
twice(5, (result) => console.log(result));
twice calls cb with n * 2.
Here n is 5, so the callback receives 10.
Write greet(name, cb) that calls the callback cb with the string "hello, " + name. The function does the work by invoking cb; it does not need to return anything.
function greet(name, cb) {
// call cb with the greeting string
}
Inside the body, call cb(...) with the built string.
String concatenation with + joins "hello, " and name.
function greet(name, cb) {
cb("hello, " + name);
}
Write parseScore(input, cb) using the error-first convention. If input is a number, call cb(null, input). Otherwise call cb(new Error("not a number"), null).
function parseScore(input, cb) {
// error-first: cb(null, value) on success, cb(error, null) on failure
}
Check typeof input === "number" to tell success from failure.
Success passes (null, input); failure passes (new Error("not a number"), null).
function parseScore(input, cb) {
if (typeof input !== "number") {
cb(new Error("not a number"), null);
} else {
cb(null, input);
}
}
This error-first function is meant to hand the callback a user object, but the author swapped the order and called cb(user, null) instead of cb(null, user). So a caller reading the first argument as the error sees the user object, not null. Swap the arguments so the error comes first.
function loadName(id, cb) {
cb({ name: "Ada" }, null);
}
Error-first means the first argument is the error (null on success).
Call cb(null, { name: "Ada" }) so the result sits in the second position.
function loadName(id, cb) {
cb(null, { name: "Ada" });
}
You run this code. In what order do the lines print?
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");
A and D are synchronous, so they print first in order. The .then callback is a microtask and the setTimeout callback is a macrotask; between macrotasks the engine drains all microtasks, so C prints before B.
Recap
- JavaScript runs on a single thread; a blocking statement freezes everything behind it, so slow work has to be asynchronous.
- The call stack holds running code; finished async tasks wait in queues and run only when the stack is empty.
- Microtasks (resolved Promises) drain fully between macrotasks (
setTimeout, events), which is why a resolved Promise beats a zero-delay timeout. - A callback is a function passed in to be called later with the result; the error-first convention reserves the first argument for an error.
- Nested callbacks curl into callback hell, the problem Promises and async/await were built to solve.
Next you will meet Promises, a dedicated object that turns 'arrange to be told' into a value you can actually chain.