Async/await vs. callbacks vs. promises/futures — what's actually different under the hood?
They all solve the same problem — do this after that finishes — but each one changes where control returns, how errors propagate, and what the call stack looks like when something breaks.
LEARN BY DOINGPut your intuition to work3 steps
Async/await vs. callbacks vs. promises/futures — what's actually different under the hood?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#They are not three competing mechanisms
A question worth asking back, mentally, before answering: async/await, promises, and callbacks are not three different ways of achieving concurrency. In a single-threaded runtime like Node.js, there is exactly one mechanism underneath all three: the event loop, non-blocking system calls, and a task-scheduling queue. Callbacks, promises, and async/await are three layers of syntax built on top of that one mechanism, each solving a real ergonomic or safety problem the previous layer had.
Understanding "what's different under the hood" means understanding what each layer actually compiles down to, not just which one looks nicer.
#Callbacks: a function reference, nothing more
A callback is the most primitive form: you pass a function reference to something that will call it later, once an operation completes. There is no object representing "the eventual result" — just a promise (in the informal sense) that someone will call your function eventually, with an error-first argument convention if you are following Node's tradition.
function getUserCallback(id, callback) {db.query('SELECT * FROM users WHERE id = ?', [id], function (err, user) {if (err) return callback(err);db.query('SELECT * FROM orders WHERE user_id = ?', [user.id], function (err2, orders) {if (err2) return callback(err2);callback(null, { user: user, orders: orders });});});}
The problem is not aesthetics, it is correctness: every single callback has to remember to check its own error, and nothing forces that. Forget one if (err) check and the error silently falls through, or worse, both branches run. Nesting also makes it hard to return early or share cleanup logic — this is the "pyramid of doom" everyone complains about, but the real cost is how easy it is to write a bug into it.
#Promises: a real object, and a real queue
A promise is an object with an internal state machine (pending → fulfilled or rejected, and it can only transition once). Because it is an object, it can be returned, stored, composed with Promise.all, and chained with .then/.catch — the runtime, not the caller, is responsible for calling your continuation once the state settles.
function getUserPromise(id) {return db.query('SELECT * FROM users WHERE id = ?', [id]).then(function (user) {return db.query('SELECT * FROM orders WHERE user_id = ?', [user.id]).then(function (orders) {return { user: user, orders: orders };});});}
This is where the event loop detail actually matters for interviews: promise callbacks (the functions you pass to .then) run in the microtask queue, not the same queue as timers or I/O callbacks (the macrotask queue). After every synchronous chunk of code finishes, the JavaScript runtime drains the entire microtask queue before it looks at the next macrotask. That ordering is precise and deterministic, and it is exactly why this classic snippet prints in a specific, non-obvious order:
console.log('A');setTimeout(function () { console.log('B'); }, 0);Promise.resolve().then(function () { console.log('C'); });console.log('D');// Prints: A, D, C, B
#Async/await: the same promises, wearing sequential clothing
async/await does not introduce a new concurrency primitive. An async function always returns a promise, and await is sugar for: suspend this function's execution at this point, register the rest of the function as a continuation on the awaited promise (scheduled as a microtask when it settles), and return control to the caller immediately. Conceptually it is the same transformation a generator-based coroutine library used to do by hand before the language added it natively.
async function getUserAsync(id) {const user = await db.query('SELECT * FROM users WHERE id = ?', [id]);const orders = await db.query('SELECT * FROM orders WHERE user_id = ?', [user.id]);return { user: user, orders: orders };}
What you gain is not a different mechanism, but real language control flow: try/catch works exactly the way it does in synchronous code, because the engine is genuinely unwinding a call stack, not routing you through an error-first callback argument or a .catch you might forget to attach. What you can lose, if you are not careful, is parallelism you had for free with promises — awaiting two independent calls one after another serializes them, where Promise.all would have run them concurrently.
#Comparing the three
| Aspect | Callbacks | Promises | Async/await |
|---|---|---|---|
| What represents "eventually" | nothing - just a stored function reference | a state-machine object (pending/fulfilled/rejected) | the same promise object, hidden behind syntax |
| Composition | manual nesting | .then chains, Promise.all/allSettled | linear code; still needs Promise.all for real parallelism |
| Error handling | error-first argument, must be checked manually every time | .catch, propagates automatically through the chain | try/catch, real stack unwinding |
| Classic bug | pyramid of doom, a missed error check | an unreturned .then, an unhandled rejection | a forgotten await, or accidental serialization in a loop |
#Key takeaways
- All three sit on top of the same single-threaded event loop; they differ in syntax and safety guarantees, not in the underlying execution model.
- Promise callbacks run as microtasks, which fully drain before the next macrotask (timers, I/O callbacks) — this governs ordering questions interviewers love to ask.
async/awaitis sugar over promises: it buys you realtry/catchand linear-looking code, but sequentialawaits can accidentally serialize work that could have run in parallel.- The engine-level ordering guarantee - microtasks always drain before the next macrotask - is worth memorizing verbatim, since it is one of the most reliably asked "predict the output" questions in async interviews.