Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Beginner/B15
15 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSB15 · Part 1 · Beginner

Callbacks, then promises

Handing work to someone else, and getting the answer back later.

9 min read11 sections3 exercises
15 of 41

In this chapter

  • 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

and 6 more

about 9 min
11On this page
  1. A callback is just a function you hand over
  2. setTimeout and setInterval — your first parked callbacks
  3. The callback shape that Node made standard
  4. Callback hell is not about indentation
  5. A promise is an object for a value that isn't here yet
  6. Making one yourself
  7. Chaining, and the one rule that governs it
  8. async / await is the same promises, different spelling
  9. fetch — asking the network for something
  10. JSON.stringify / JSON.parse
  11. 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/catch does not work. By the time the callback runs, the try block around it has long since finished — there is no stack frame left to catch anything.
  • You cannot return a 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.

The life of one promise
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 printedLineWhy it runs when it does
1stconsole.log("1")synchronous — runs immediately, in place
2ndconsole.log("4")also synchronous — the rest of the script keeps running before anything parked gets a turn
3rdconsole.log("3")a microtask (promise reaction) — runs the instant the call stack is empty, before any timer
4thconsole.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 async function always returns a promise — even if its body has no await and returns a plain number, that number gets wrapped.
  • await unwraps. It pauses the function until the promise settles and gives you the value, not the promise.
  • try/catch works 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.
  • .then returns a new promise. Return from every callback or the next step gets undefined.
  • One .catch at the end covers the whole chain — and try/catch works again once you are using await.
  • fetch only rejects on network failure. Check response.ok yourself.
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, and setTimeout(fn, 0) — microtasks always drain before the next macrotask.
  • Explain why a try/catch around a parked callback never catches the error, and why await fixes that.
  • Trace a .then chain where one step forgets to return, and say exactly where undefined enters it.
  • Explain why a "successful" fetch to a 404 still resolves, and what response.ok is for.
  • Say what JSON.stringify does with undefined and functions as object values versus inside an array.

03Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsWrap setTimeout in a promisebeginner
  • 8 testsRun promises one after anotherbeginner
  • 8 testsClean an object for storagebeginner
All 35 for this level →
B15

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. B14Previous · B14DOM & events
  2. B15you are here
  3. Next · B16Errors & toolsThe last lesson before the beginner project — reading what the engine is trying to tell you.6 min read

01On this page

11 sections · 0%

  1. 01A callback is just a function you hand over
  2. 02setTimeout and setInterval — your first parked callbacks
  3. 03The callback shape that Node made standard
  4. 04Callback hell is not about indentation
  5. 05A promise is an object for a value that isn't here yet
  6. 06Making one yourself
  7. 07Chaining, and the one rule that governs it
  8. 08async / await is the same promises, different spelling
  9. 09fetch — asking the network for something
  10. 10JSON.stringify / JSON.parse
  11. 11What to carry forward

[] chapters · t top