From Callbacks to Async/Await: How Asynchronous JavaScript Actually Works
Most of us learn async/await, Get our code working, and never look back. The syntax is clean, the mental model feels synchronous, and life…
From Callbacks to Async/Await: How Asynchronous JavaScript Actually Works

Most of us learn async/await, Get our code working, and never look back. The syntax is clean, the mental model feels synchronous, and life is good — until a promise rejects somewhere with no catch, a Node process crashes in production, or someone asks you whyit try/catch doesn't work the way you expect inside a callback.
This article walks through the entire async story in JavaScript: callbacks, the error-first convention, promises, and async/await. Not as isolated syntax to memorize, but as one continuous evolution where each step exists to fix a real problem in the step before it. By the end, the behavior that used to feel arbitrary should feel inevitable.

Callbacks: where it all started
A callback is just a function passed as an argument to another function, to be run later.
const fs = require('fs');
fs.readFile('data.txt', (err, data) => {
console.log(data);
});
fs.readFile doesn't block while it reads from disk. It registers that arrow function and moves on. When the read finishes, Node calls your function back — hence the name. For every asynchronous event, Node lets you register a callback to handle the result.
The error-first pattern
If you look closely at the callback signature above, the first parameter is err, not data. That's not a coincidence — it's a convention baked into Node's core APIs:
fs.readFile('file.txt', (err, data) => {
if (err) return console.error(err);
console.log(data);
});
The rule is simple:
err === null→ successerr !== null→ failure
Why error-first? Why not just throw?
This is the question that unlocks everything about async error handling.
Asynchronous operations run later — after the function that started them has already returned. By the time readFile actually fails, the surrounding synchronous code has long since finished executing. There is no try/catch left on the stack to catch anything. The error has nowhere to bubble up to.
So Node doesn’t throw. It passes the error to you manually, as the first argument:
callback(error, result);
Once you internalize this — that async errors can’t travel up the call stack the way synchronous ones do — the next problem becomes obvious.
Why try/catch fails with async callbacks?

Beginners reach for the tool they know:
try {
fs.readFile('file.txt', () => {
throw new Error('boom');
});
} catch (error) {
console.log(error); // this never runs
}
That catch will never fire. Here's the sequence of events:
- The
tryblock executesfs.readFileand finishes immediately. - The callback runs later, in a completely new execution stack.
- By then, the original
try/catchis gone.
When the callback finally throwsboom, the catch That was supposed to protect it no longer exists on the stack. The error escapes and crashes the process.
Where try/catch does work
try/catch isn't useless in async code — it just only works around synchronous logic. Two valid places:
- Around synchronous code:
try {
JSON.parse('..invalid json..');
} catch (error) {
console.error(error);
}
- Inside a callback, for the synchronous work happening within it:
fs.readFile('file.txt', (err, data) => {
try {
JSON.parse(data); // synchronous — try/catch works here
} catch (e) {
console.error(e);
}
});
Callback hell

Callbacks work, but they don’t compose. The moment one async operation depends on the result of another, you start nesting:
getUser(id, (err, user) => {
getOrders(user.id, (err, orders) => {
getOrderDetails(orders[0], (err, details) => {
// ...and we keep drifting right
});
});
});
This is the pyramid of doom: callbacks nested inside callbacks, many levels deep. It’s hard to read, hard to maintain, and genuinely difficult to debug. Error handling has to be repeated at every level. Something better was needed.
Promises: a placeholder for a future value
Instead of returning the final value immediately (impossible — it isn’t ready yet) or demanding a callback up front, an async operation hands you a placeholder object: a promise. You hold onto it, and it eventually settles into a result.
A promise is always in exactly one of three states:
- pending — still working, no result yet
- fulfilled — finished successfully (via
resolve()) - rejected — failed (via
reject())
Once a promise moves from pending to either fulfilled or rejected, it is settled. And here’s the crucial guarantee:
Once a promise is settled, it is immutable. It can never change its state again.
This is what makes promises trustworthy. A function can’t accidentally resolve your promise twice and run your success handler twice. Only the first resolve or reject counts — every call after that is ignored.
Two handlers connect to the two outcomes:
- if
resolve()was called →.then()runs - if
reject()was called →.catch()runs
The detail most people miss: .then() chaining
This is where promises stop being “callbacks with nicer syntax” and become something genuinely more powerful.
*.then() always returns a brand new promise object, immediately*
And inside a .then(), JavaScript automatically unwraps any promise you return — it waits for that inner promise to settle, extracts the value, and passes it to the next .then() in the chain:
getUser(id)
.then(user => getOrders(user.id)) // returns a promise...
.then(orders => console.log(orders)) // ...but JS unwraps it for us
.catch(handleError);
You never have to manually dig the value out of the promise returned by getOrders. JS unwraps it before calling the second .then(). And if you return a plain value inside .then(), the new promise simply resolves with it automatically.This unwrapping is the entire reason the pyramid of doom flattens into a clean vertical chain.
Running promises in parallel: Promise.all()
Promise.all() lets you run multiple promises in parallel and wait for all of them to finish.
const results = await Promise.all([promise1, promise2, promise3]);
It returns a new promise that:
- Resolves when all the promises resolve (you get an array of results)
- Rejects immediately the moment any one promise rejects
When you want every result, even the failures: Promise.allSettled()

async/await is syntactic sugar over promises — but understanding what it desugars to is what separates confident code from cargo-culting.
Every async function returns a promise
Any function marked async automatically returns a promise, wrapping whatever value you returned (or undefined if you returned nothing).
async function test() {
return 'hello';
}
test(); // Promise { 'hello' }
What await actually does
await pauses your function and waits for a promise to settle:
- if the promise fulfills → it gives you back the value
- if the promise rejects → it throws the error
That second point is the magic. Because await turns a rejection into a regular thrown error, normal try/catch suddenly works again:
async function readConfig() {
try {
const data = await readFile('file.txt');
return JSON.parse(data);
} catch (err) {
// handle error here — and it actually fires
}
}
Step by step:
readFilereturns a promise.- The promise rejects.
awaitturns that rejection into a thrown error.catchcatches it.
A useful mental model: when you await, you're telling the engine "pause this function, unwrap the promise in the background, and hand me the value directly." Node unwraps the promise for you — exactly like .then() did, just without the callback.
Does await block Node? (No.)
**await only pauses the function it's inside.** The event loop keeps running, other tasks keep executing, other requests keep getting served. You've paused one function, not the runtime.
Error handling, properly
With callbacks, error handling is manual and repetitive. You check if (err) at every single level, and forgetting one silently swallows a failure.
With promises and async/await, error handling is centralized:
getUser(id)
.then(user => getOrders(user.id))
.then(orders => process(orders))
.catch(handleError); // one catch for the entire chain
One .catch() can handle every error in the chain. One try/catch can wrap an entire sequence of awaits. The error-handling logic lives in one place instead of being scattered across every step.
Unhandled rejections — and why Node now crashes on them
So what happens if a promise rejects and there’s no .catch() and no surrounding try/catch? It becomes an unhandled promise rejection, and Node's behavior here changed in an important way:
- Old Node (before v15): Node just printed a warning and kept running.
- Modern Node (v15+): Node crashes the process — it throws and exits.
That sounds harsh, but it’s the safer default. Why? Because unhandled rejections are dangerous:
- They can leave a database transaction half-finished.
- They can leak memory.
- They can leave your server in an inconsistent state.
Putting it together

The progression isn’t three competing styles — it’s one problem being solved, refined, and solved again:
- Callbacks gave us asynchrony, but cost us composition and control.
- Promises gave back control and flattened the pyramid, with automatic unwrapping and centralized error handling.
- Async/await wrapped promises in synchronous-looking syntax, making
try/catchwork again — without ever blocking the event loop.
You can write modern JavaScript using only async/await and never type .then() again. But knowing what sits underneath — that an async function is a promise, that await throws on rejection, that unhandled rejections will crash your process — is the difference between code that works on your machine and code you can trust in production.
That’s the whole point of understanding the layers, not just the syntax on top.
메타데이터
- post_id
- f9ae031e5c7a
- slug
- from-callbacks-to-async-await-how-asynchronous-javascript-actually-works-f9ae031e5c7a
- url
- https://medium.com/@dhulanjala/from-callbacks-to-async-await-how-asynchronous-javascript-actually-works-f9ae031e5c7a
- canonical_url
- https://medium.com/@dhulanjala/from-callbacks-to-async-await-how-asynchronous-javascript-actually-works-f9ae031e5c7a
- author_url
- https://medium.com/@dhulanjala
- status
- ok
- fetched_at
- 2026-07-29 13:52:11