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).
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.
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 fails | callbacks | promises | async/await |
|---|---|---|---|
| getOrders answers HTTP 500 | if (err) in the inner callback ✓ | skips then #2, lands in .catch ✓ | await throws into catch ✓ |
| the same, handling forgotten | render(undefined): Uncaught TypeError ✗ | Uncaught (in promise) ✗ | Uncaught (in promise) ✗ |
render() throws | Uncaught 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).
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.