B15 · Part 1 · Beginner
Callbacks, then promises
Handing work to someone else, and getting the answer back later.
11On this page
- A callback is just a function you hand over
- setTimeout and setInterval — your first parked callbacks
- The callback shape that Node made standard
- Callback hell is not about indentation
- A promise is an object for a value that isn't here yet
- Making one yourself
- Chaining, and the one rule that governs it
- async / await is the same promises, different spelling
- fetch — asking the network for something
- JSON.stringify / JSON.parse
- What to carry forward
You already know why slow work gets handed off: one thread, and the runtime does the waiting — the single-thread chapter covered the machine. This chapter is the other half: the two shapes your code takes when it has to collect the answer afterwards. Callbacks came first, promises replaced them, and every promise is still a callback underneath.
A callback is just a function you hand over
There is nothing asynchronous about a callback by itself. It is a function passed to other code so that code can call it. You have been writing them since arrays:
[1, 2, 3].forEach(function (n) { console.log(n); });
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ a callback. forEach calls it, three times, immediately.
That one is synchronous — it runs and finishes before the next line. The interesting case is when the code you handed it to does not call it now, but keeps it for later:
setTimeout(function () { console.log("later"); }, 1000);
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ same idea, parked for a second
Same mechanism, different timing. "Callback hell" and promises are not about the function-passing part — that part is fine. They are about what happens when you need several of the parked kind, in order.
setTimeout and setInterval — your first parked callbacks
const id = setTimeout(() => {
console.log("ran once, after the delay");
}, 1000); // milliseconds — 1000 = 1 second
clearTimeout(id); // cancel it before it fires
const tick = setInterval(() => {
console.log("runs again, and again, every 500ms");
}, 500);
clearInterval(tick); // the ONLY way to make it stop
⚠ The delay is a minimum, not a guarantee
setTimeout(fn, 0) does not run immediately — it means
"as soon as the call stack is empty and it's this callback's turn,"
which could be much later if the thread is busy. A timer can never
interrupt code that is already running.
let count = 0;
await new Promise((resolve) => {
const id = setInterval(() => {
count++;
console.log("tick", count);
if (count === 3) { clearInterval(id); resolve(); }
}, 50);
});
Run it — three ticks, then silence. (The await is only so
this sandbox waits for all three before calling the run finished.)
Forgetting clearInterval in real code is a common memory
leak: the interval holds on to everything its callback closes over,
forever, long after the UI it was updating is gone.
The callback shape that Node made standard
A parked callback has a problem a synchronous one does not: if the
work fails, there is nobody left to throw at. The
function that started it has already returned. So the error has to be
passed in, and the convention became
error-first — the callback's first parameter is either an error
or null.
function readConfig(callback) {
fs.readFile("config.json", (err, data) => {
if (err) return callback(err); // error path checked FIRST, always
callback(null, JSON.parse(data));
});
}
Callback hell is not about indentation
The staircase everyone complains about shows up the moment one async step needs the result of the last:
getUser(id, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getInvoice(orders[0].id, (err, invoice) => {
if (err) return handleError(err);
render(invoice); // four levels deep and still growing sideways
});
});
});
The indentation is the symptom. The actual problems are three, and promises were designed to fix exactly these:
-
Error handling repeats at every level. Miss one
if (err)and the failure vanishes silently. -
try/catchdoes not work. By the time the callback runs, thetryblock around it has long since finished — there is no stack frame left to catch anything. -
You cannot
returna result. The value has to be handed deeper inwards instead of back out, so nothing composes.
function later(value, cb) { setTimeout(() => cb(value), 10); }
try {
later("hi", (v) => {
throw new Error("thrown inside the callback");
});
} catch (e) {
console.log("caught it:", e.message); // does this run?
}
console.log("try/catch finished");
await new Promise((r) => setTimeout(r, 50)); // hang around long enough for the throw
Run it. "try/catch finished" prints and the catch never
does — the throw happens 10ms later, on a completely empty stack, with
the try long gone. The error escapes as an uncaught
error instead. This is the single strongest argument for promises.
A promise is an object for a value that isn't here yet
Instead of handing your function in and hoping, you get an object back now that stands for the eventual result. You attach your reaction to that object. It has exactly three states:
| State | Meaning | Can it change again? |
|---|---|---|
pending |
not settled yet | yes — once, to fulfilled or rejected |
fulfilled |
succeeded, and has a value | no — permanent |
rejected |
failed, and has a reason | no — permanent |
Settled means fulfilled or rejected — either way, finished
forever. That permanence is the whole point: a promise is a
record of an outcome, not an event you can miss. Attach a
.then() an hour late and it still fires, with the same
value.
Promise state
Reactions registered on it
Microtask queue
Console
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
Dry run: sync code, a microtask, and a macrotask, in order
| Order printed | Line | Why it runs when it does |
|---|---|---|
| 1st | console.log("1") | synchronous — runs immediately, in place |
| 2nd | console.log("4") | also synchronous — the rest of the script keeps running before anything parked gets a turn |
| 3rd | console.log("3") | a microtask (promise reaction) — runs the instant the call stack is empty, before any timer |
| 4th | console.log("2") | a macrotask (timer callback) — the event loop only looks at the timer queue after the microtask queue is fully drained |
Output is 1 4 3 2, never 1 2 3 4 — the
0ms in setTimeout(fn, 0) never competes with
synchronous code or with microtasks, it only ever gets a turn after
both are completely empty. Every .then reaction is a
microtask, and the entire microtask queue always drains before the
next macrotask runs, even one queued with a 0ms delay.
Making one yourself
const wait = (ms) =>
new Promise((resolve, reject) => {
if (ms < 0) return reject(new Error("negative delay"));
setTimeout(() => resolve("waited " + ms + "ms"), ms);
});
wait(100).then((msg) => console.log(msg));
You will write new Promise far less often than you think
— almost everything already hands you one. It is for wrapping the old
callback-style APIs that do not, like setTimeout above.
Wrapping something that already returns a promise is a
well-known anti-pattern with a name: the explicit promise
construction antipattern.
Chaining, and the one rule that governs it
.then() always returns a new promise. What that
new promise settles with depends entirely on what your callback
returned — this table is the whole of promise chaining:
Your .then callback returns |
The next .then receives |
|---|---|
| a plain value | that value |
| another promise | waits for it, then its value — chains stay flat |
| nothing | undefined — the classic "why is it undefined" bug |
| throws | skipped — control jumps to the next .catch |
await Promise.resolve(2)
.then((n) => n * 10) // plain value
.then((n) => new Promise((r) => setTimeout(() => r(n + 5), 20))) // a promise — flattened
.then((n) => { console.log("got", n); }) // returns nothing...
.then((n) => console.log("and now", n)) // ...so this gets undefined
.catch((e) => console.log("never reached"));
Run it: got 25, then and now undefined. The
missing return in the third step is the most common
promise bug there is — and it is silent.
.catch() is just .then(null, fn) with a
friendlier name, and one at the end of a chain catches a failure from
any step above it. .finally() runs either way and
passes the value straight through, exactly like
try/finally.
loadUser()
.then((user) => loadOrders(user.id))
.then((orders) => render(orders))
.catch((error) => showError(error)) // one handler for all three steps
.finally(() => hideSpinner());
Compare that to the staircase earlier: the same three dependent steps, flat, with error handling written once. That is the fix.
async / await is the same promises, different spelling
async function loadInvoice(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
return await getInvoice(orders[0].id);
} catch (error) {
console.error("failed:", error);
throw error; // re-throw so the caller still knows
}
}
Three things to be precise about:
-
An
asyncfunction always returns a promise — even if its body has noawaitand returns a plain number, that number gets wrapped. -
awaitunwraps. It pauses the function until the promise settles and gives you the value, not the promise. -
try/catchworks again. A rejected awaited promise throws exactly like a synchronous error — which is the thing callbacks could not do.
⚠ await inside a loop is sequential
for (const id of ids) await load(id); waits for each
request before starting the next. If they do not depend on each other
that is ten round trips where one would do —
Async, properly covers
Promise.all and the rest of the combinators.
fetch — asking the network for something
fetch("/api/users/1")
.then(response => response.json()) // parses the body as JSON — itself async
.then(data => console.log(data))
.catch(error => console.error("request failed:", error));
fetch resolves as soon as the server sends back
any response — even a 404 or a 500. It only rejects on a real
network failure (offline, DNS gone, CORS blocked). That means status
codes need their own check:
async function getUser(id) {
const response = await fetch("/api/users/" + id);
if (!response.ok) { // true for 200-299, false for 404/500/etc.
throw new Error("Request failed: " + response.status);
}
return response.json();
}
Rule A rejected
fetch promise
means the network itself failed. A "successful" 404 still resolves —
always check response.ok before trusting the body.
JSON.stringify / JSON.parse
Every one of those responses arrives as text, so the last piece is the conversion between JavaScript objects and JSON — and that conversion drops things silently.
const obj = { a: 1, b: undefined, c: function () {}, d: [1, undefined, 2] };
console.log(JSON.stringify(obj)); // what happens?
{"a":1,"d":[1,null,2]} — b and
c vanish completely, because JSON has no way to
represent undefined or a function as a
property value. Inside an array, though, the same
undefined can't just be skipped without shifting every
index after it — so it becomes null instead.
JSON.stringify({ a: 1, b: 2 }, null, 2);
// {
// "a": 1,
// "b": 2
// } — the third argument is indent width, for readable output
JSON.parse('{"a":1,"b":[1,2,3]}'); // back to a real object — { a: 1, b: [1, 2, 3] }
⚠ A circular reference throws
const o = {}; o.self = o; JSON.stringify(o); throws
TypeError: Converting circular structure to JSON —
stringify walks the whole object graph and has no way to
represent a reference back to something it's already visiting.
This pairing is also the standard, dependency-free way to deep-clone a plain object — with real limits:
const clone = JSON.parse(JSON.stringify(original));
Works for plain data — objects, arrays, strings, numbers, booleans,
null. Silently mangles anything else: Date
becomes a string, Map/Set become
{}, functions and undefined vanish exactly
as above. Fine for a config blob; wrong for cloning anything richer —
structuredClone() (built into every modern runtime) does
a real deep clone, Dates and Maps included.
What to carry forward
- A callback is only a function you hand over. The trouble was never that — it was errors and composition.
- A promise settles once, permanently, and its reactions always run as microtasks. Never synchronously, even when it is already resolved.
-
.thenreturns a new promise. Return from every callback or the next step getsundefined. -
One
.catchat the end covers the whole chain — andtry/catchworks again once you are usingawait. -
fetchonly rejects on network failure. Checkresponse.okyourself.
Interview answer, one sentence
"A callback hands work to something else to call back later; a promise is an object for a value that is not here yet, and async/await is the same promises with synchronous-looking syntax — the rule to remember is that every .then returns a new promise, so a chain flows through it and an error skips ahead to the nearest catch."
Before you move on
- Order a mix of sync code, a
.then, andsetTimeout(fn, 0)— microtasks always drain before the next macrotask. - Explain why a
try/catcharound a parked callback never catches the error, and whyawaitfixes that. - Trace a
.thenchain where one step forgets toreturn, and say exactly whereundefinedenters it. - Explain why a "successful"
fetchto a 404 still resolves, and whatresponse.okis for. - Say what
JSON.stringifydoes withundefinedand functions as object values versus inside an array.
Opens in the editor: write it, run it, and check it against real tests.
All 35 for this level →Finished reading?
0 of 41 chapters read in this series