The idea: one task, three spellings

A page has to fetch a user, then that user's orders, then render them. Each step waits on the network, and JavaScript has one thread, so it cannot just stop and wait. It starts a request, goes on with other work, and continues later. JavaScript has three ways to write "continue later": callbacks, promises and async/await. The page runs the same program written all three ways, side by side, with the same network times (GET /user/7 takes 20 ms, GET /orders 30 ms).

The same task in three styles. Callbacks: getUser with a callback that calls getOrders with a callback that calls render, nested two levels deep, with an if (err) check at each level. Promises: getUser(7) then getOrders then render then one catch, a flat chain. Async/await: inside try, await getUser, await getOrders, render, then one catch block.
Same steps, three shapes: a pyramid of nested callbacks, a flat chain, and straight lines.

Each column on the canvas is its own runtime: a call stack, the network (a Web API that works outside JavaScript), a task queue and a microtask queue. The yellow bar marks the line that is running. In Demo 1: happy path, same 50 ms all three print "requested" first, start getOrders at t = 20 ms and render at t = 50 ms. The timing is the same. What differs is the shape of the code and where errors go.

Callbacks: pass the rest of the work

With callbacks you give the async function a function to call when it is done. Node.js style is error-first: cb(err, value), with err = null on success. Each next step lives inside the previous callback, so three steps make a pyramid ("callback hell"). When the response comes, the network queues a task, and that task is the callback: it runs as soon as the call stack is empty. The callback column uses no microtasks at all.

Promises: a value that comes later

getUser(7) returns a promise at once: an object that is pending now and later fulfilled with a value or rejected with an error. .then(f) registers a handler and returns a new promise, so steps chain flat instead of nesting. If a handler returns a promise (like getOrders(user)), the next .then waits for it.

Handlers never run inside the task that settles the promise. Settling queues a microtask, and after every task the event loop runs microtasks until their queue is empty, before it takes the next task. So the promise column needs one more hop than the callback column, but no extra time: everything still happens at t = 20 ms.

What happens at t = 20 ms, top to bottom. The network makes a task. Callbacks: the task runs the callback, which starts getOrders. Promises: the task resolves p1 and queues then #1 as a microtask; the microtask then starts getOrders. Async/await: the task resolves the awaited promise and queues resume main; the microtask runs main up to the next await.
Callbacks run in the response task; promise handlers and resumed async functions run in a microtask right after it.

async/await: promises that read like plain code

An async function always returns a promise. Inside it, await p pauses the function and returns to the caller; when p settles, the rest of the function is queued as a microtask, just like a .then handler. That is why main(); log("requested") logs before any data arrives: main() runs only up to its first await. The code reads top to bottom, uses ordinary variables, and loops and ifs work as usual. Under the hood it is the same promise machinery, a function that is paused and resumed like a coroutine.

Errors in each style

Pick a failure and press Run All Three, or run Demos 2 to 5.

what failscallbackspromisesasync/await
getOrders answers HTTP 500if (err) in the inner callback ✓skips then #2, lands in .catch ✓await throws into catch ✓
the same, handling forgottenrender(undefined): Uncaught TypeError ✗Uncaught (in promise) ✗Uncaught (in promise) ✗
render() throwsUncaught Error, even with the checks ✗.catch ✓catch ✓

With callbacks an error is only an argument. Every level must check it, and forgetting one check does not stop the program: it goes on with undefined and fails later, far from the cause (Demo 3: forgotten error handling). A throw inside a callback is worse: the code that called getUser finished long ago, so no try/catch around it can catch it, and it becomes an uncaught exception (Demo 4: a throw inside the callback).

Where a throw in render goes. Callbacks: out of the callback, out of the task, uncaught exception; the if (err) checks are skipped over. Promises: the throw rejects the promise of then #2, which flows to catch. Async/await: the throw goes to the catch block of the try around it.
A throw inside a callback escapes; a promise chain or an async function turns it into a rejection that one handler catches.

In a promise chain a rejected promise and a throw inside a handler are the same thing: both skip the following .then handlers until a .catch (each skip is a microtask; the promise column counts 3 microtasks in Demo 2 instead of 2). In an async function a rejected await throws at that line, so plain try/catch works. If nobody handles a rejection, the browser fires unhandledrejection and logs Uncaught (in promise) once the microtasks have run (Demo 5: render throws, nothing catches it). Node.js exits on an unhandled rejection by default.

Which one to use

New code is mostly async/await, with promises underneath and Promise.all for work that can run at the same time. Callbacks are still everywhere for events (addEventListener, streams) and in old Node.js APIs; util.promisify wraps an error-first callback API in a promise. To see the queues of the browser itself (timers, rendering, requestAnimationFrame) see how the browser runs JavaScript; for Node.js, with process.nextTick and the libuv phases, see the Node.js event loop.

What the page leaves out

Real promise resolution with a returned promise (a thenable) takes two extra microtasks, and await on a native promise takes one; the page draws one microtask per step. Each column is its own runtime, so the three never compete for one thread. Left out too: Promise.all and running requests in parallel, finally, cancellation with AbortController, timeouts, retries, callbacks that are called twice or never, and async iterators.