Async/Await vs Promises: What Actually Differs Under the Hood
What actually differs between async/await and Promises in JavaScript: execution order, memory, stack traces, and when raw Promise APIs still win in Node.js.
This article is published in English.
Sources: Async/Await vs Promises — The Real Difference Nobody Explains Clearly
Enough with "it's just syntactic sugar." Here's what's really happening under the hood with execution, memory, stack traces, and concurrency.
Ask a room full of JavaScript developers to explain how Promises differ from async/await, and most will recite the same line you've heard a hundred times:
"Async/await is nothing more than a cosmetic layer on top of Promises — it just lets asynchronous code read as if it ran synchronously."
That statement isn't wrong exactly, but it's a shortcut that hides more than it reveals. Describing async/await as "just syntactic sugar" is a bit like describing an automatic transmission as "just a cosmetic panel bolted onto a manual gearbox." It glosses over how the underlying mechanism actually functions, how errors propagate through the system, how memory gets allocated, and why picking one style over the other can meaningfully change how your app behaves once it's running in production.
If the two approaches were truly interchangeable at every level, consider these questions:
- Why do error stack traces look nothing alike when a failure originates inside an
awaitversus inside a.catch()? - Why do memory snapshots reveal different retention behavior between a chain of
.then()calls and an equivalentasyncfunction? - Why do experienced engineers still reach for raw
PromiseAPIs even in codebases that are otherwise built entirely aroundasync/await?
Let's set aside the shorthand explanations and dig into what actually happens inside the JavaScript runtime when asynchronous code executes.
1. The Core Mental Model: Push vs. Pause
To grasp why these two styles feel so different when you write and later debug them, you need to understand the distinct execution models each one relies on.
RAW PROMISE (.then CHAIN) ASYNC / AWAIT (COROUTINE)
=========================== =========================
+-----------------------+ +-----------------------+
| Initiate Task | | Initiate Task |
+-----------+-----------+ +-----------+-----------+
| |
+-----------v-----------+ +-----------v-----------+
| Register Callback | | Yield & Pause Frame |
| (Handler pushed away) | | (Thread freed up) |
+-----------+-----------+ +-----------+-----------+
| |
+-----------v-----------+ +-----------v-----------+
| Main Stack Clears | | Resume on Resolution |
| (Context lost) | | (Context restored) |
+-----------------------+ +-----------------------+
The Promise Model: Callback Registration (Push)
A Promise is essentially an object that stands in for the future outcome — success or failure — of some asynchronous work.
Take a typical .then() chain:
function getUserData(userId) {
return fetchUser(userId)
.then(user => fetchOrders(user.orderId))
.then(orders => calculateTotal(orders))
.catch(err => handleError(err));
}
Nothing here actually pauses. The getUserData function executes synchronously, start to finish, in a matter of microseconds. What it actually does is construct a Promise, attach callback functions that will fire once those Promises settle, and then immediately exit the call stack.
Later, once the network request finishes, the event loop pulls the registered callback out of the microtask queue and pushes it onto a brand-new, otherwise empty call stack. The original function that kicked off this chain has already disappeared entirely.
The Async/Await Model: Coroutine Suspension (Pause & Resume)
async/await is implemented on top of ECMAScript generators and coroutine mechanics.
async function getUserData(userId) {
try {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.orderId);
return calculateTotal(orders);
} catch (err) {
handleError(err);
}
}
When the engine reaches an await expression, it doesn't discard the function — it suspends that specific stack frame in place.
- Control returns to the main thread, freeing it up to do other work — repainting the UI in a browser, or handling other incoming requests in Node.js.
- Everything about the function's local state — its variables, its scope, its position — is kept intact in memory.
- Once the awaited Promise resolves, the engine feeds the resulting value back into that exact same execution context and picks up execution exactly where it left off.
This one distinction — handing off a detached callback to be run later, versus freezing and later reviving an execution frame — accounts for nearly every practical difference you'll notice between the two styles.
2. The Anatomy of a Call Stack: Why Debugging Differs
Chasing down production bugs is already one of the more painful parts of working with JavaScript, and the gap between Promise chains and async/await shows up immediately once you start reading logs.
What a Promise Stack Trace Looks Like
Take a Promise chain where the failure happens several layers deep inside a network call:
function stepOne() {
return stepTwo();
}
function stepTwo() {
return stepThree();
}
function stepThree() {
return fetch('https://invalid-url-domain-does-not-exist.xyz')
.then(res => res.json());
}
stepOne().catch(err => console.error(err.stack));
When it breaks, the resulting trace typically looks like this:
TypeError: Failed to fetch
at stepThree (<anonymous>:10:5)
at processTicksAndRejections (node:internal/process/task_queues:95:5)
Spot what's absent? Neither stepOne nor stepTwo shows up anywhere in that trace.
By the time the network call actually failed, both stepOne and stepTwo had long since returned and been popped off the stack. The error-handling code ran on a fresh microtask tick, completely disconnected from where the call chain originated. If stepThree happens to be a shared utility invoked from fifty different places in your codebase, the trace gives you no way to tell which caller triggered this particular failure.
What an Async/Await Stack Trace Looks Like
Now rewrite the same logic using async/await:
async function stepOne() {
return await stepTwo();
}
async function stepTwo() {
return await stepThree();
}
async function stepThree() {
const res = await fetch('https://invalid-url-domain-does-not-exist.xyz');
return await res.json();
}
(async () => {
try {
await stepOne();
} catch (err) {
console.error(err.stack);
}
})();
Here's the trace it produces:
TypeError: Failed to fetch
at stepThree (<anonymous>:10:21)
at async stepTwo (<anonymous>:6:10)
at async stepOne (<anonymous>:2:10)
at async <anonymous>:15:5
Because the engine suspends the frame instead of tearing it down, modern JavaScript engines — V8 in Chrome and Node.js among them — are able to reconstruct the async call history at essentially no runtime cost. The engine keeps track of the continuation through every suspension point, so the trace shows the entire path that led to the failing call inside stepThree.
3. The "Return Await" Trap: A Crucial Edge Case
There's a long-running disagreement over whether return await promise is redundant compared to simply writing return promise.
Outside of a try/catch block, returning the Promise directly saves you one extra microtask tick, so return promise is technically leaner. But inside a try/catch, that single word — await — completely changes how errors are caught.
The Broken Version
async function fetchUserData(userId) {
try {
// BUG: Returning the promise directly without awaiting it
return api.getUser(userId);
} catch (error) {
console.error("Failed to fetch user, applying fallback...", error);
return getFallbackUser();
}
}
Half a second later, when the Promise actually rejects, the try/catch block has already completed its run. The catch clause never fires, because it's no longer part of the active execution path. Instead, the rejection escapes your local error handling entirely and surfaces as an unhandled rejection somewhere up the call chain, wherever fetchUserData was invoked from.
async function fetchUserData(userId) {
try {
// CORRECT: Await forces resolution BEFORE exiting the try block
return await api.getUser(userId);
} catch (error) {
console.error("Failed to fetch user, applying fallback...", error);
return getFallbackUser();
}
}
Adding await fixes this because it instructs the engine to pause at that line and stay inside the current try block until the Promise settles. If it rejects, execution remains in that context long enough for the catch block to catch it, rather than letting the function return before the failure even happens.
4. Scope and Shared State: Escaping "Scope Gymnastics"
Nested Promise chains become especially clumsy when a later step needs data produced by an earlier one.
Imagine you need to fetch a user, then their profile, and finally generate a receipt that depends on data from both steps:
function generateReceipt(userId) {
return getUser(userId)
.then(user => {
return getProfile(user.profileId)
.then(profile => {
// You are forced to nest here just to access `user` and `profile` together
return createReceipt(user, profile);
});
});
}
To avoid the pyramid shape, developers often fall back on declaring variables outside the chain just so later .then() callbacks can reach them:
function generateReceipt(userId) {
let tempUser; // Leaking scope variable
return getUser(userId)
.then(user => {
tempUser = user;
return getProfile(user.profileId);
})
.then(profile => {
return createReceipt(tempUser, profile);
});
}
async/await removes the need for this workaround, because every variable you declare stays in the same function-level scope for the whole lifetime of the operation:
async function generateReceipt(userId) {
const user = await getUser(userId);
const profile = await getProfile(user.profileId);
return createReceipt(user, profile);
}
There's no outer variable to predeclare, no nested callback structure, and no need to trace back through the chain to figure out where a value came from.
5. Performance and Concurrency: The Sequential Pitfall
async/await makes code easier to read, but it opens the door to a costly mistake: unintentionally serializing operations that could run at the same time.
Developers moving from Promise chains to async/await often end up writing something like this without realizing the performance cost:
// SLOW: 3 sequential network requests (Waterfall)
async function loadDashboardData(userId) {
const user = await fetchUser(userId); // Takes 200ms
const posts = await fetchPosts(userId); // Takes 200ms (waits for user)
const stats = await fetchStats(userId); // Takes 200ms (waits for posts)
return { user, posts, stats }; // Total time: 600ms
}
Because each await blocks the function until that call resolves, fetchPosts won't even start until fetchUser has completely finished — despite the fact that fetchPosts and fetchStats have no dependency on fetchUser's result at all.
SEQUENTIAL (Slow Waterfall):
[-- fetchUser (200ms) --] -> [-- fetchPosts (200ms) --] -> [-- fetchStats (200ms) --] = 600ms
CONCURRENT (Optimized):
[-- fetchUser (200ms) --]
[-- fetchPosts (200ms) --]
[-- fetchStats (200ms) --] = 200ms (Total)
The Fix: Combine async/await with Promise Combinators
async/await isn't a substitute for the Promise utility methods — it's meant to be paired with them.
// FAST: All 3 requests start immediately in parallel
async function loadDashboardData(userId) {
const [user, posts, stats] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchStats(userId)
]);
return { user, posts, stats }; // Total time: ~200ms
}
6. Loops and Iteration: The Array Method Trap
A common source of bugs in current JavaScript codebases comes from combining async callbacks with array methods such as .forEach(), .map(), or .filter().
async function saveUsers(users) {
users.forEach(async (user) => {
await db.users.insert(user);
});
console.log("All users saved!"); // LIES: This logs BEFORE the database calls finish!
}
This fails because Array.prototype.forEach is inherently synchronous — it never looks at what the callback returns. Even though passing an async function into forEach causes each call to produce a Promise, forEach has no mechanism to wait on those Promises; it just moves straight to the next iteration.
There are two correct alternatives, depending on what you actually need.
Option A runs the work one item at a time using a for...of loop, which is appropriate when order matters or you need to avoid overwhelming a rate-limited API:
async function saveUsersSequentially(users) {
for (const user of users) {
await db.users.insert(user);
}
console.log("All users saved safely in order.");
}
Option B runs everything concurrently by pairing .map() with Promise.all, which suits cases where the operations are independent of one another:
async function saveUsersConcurrently(users) {
await Promise.all(users.map(user => db.users.insert(user)));
console.log("All users saved simultaneously.");
}
7. When Raw Promises are Still Better
async/await should be your go-to tool for linear business logic, but there are specific architectural situations where working directly with Promises is the better choice.
The first is fire-and-forget background work — situations where you want to trigger a side effect without making the caller wait for it:
async function handleUserSignup(req, res) {
const user = await db.createUser(req.body);
// Non-blocking: Fire and handle errors without awaiting
sendWelcomeEmail(user.email).catch(err => {
logger.error("Failed to send welcome email in background", err);
});
// Respond immediately without waiting for the email service
return res.json({ status: "success", user });
}
The second is managing more elaborate concurrency scenarios, where the dedicated Promise combinator methods offer purpose-built behavior:
Promise.race()lets you cut off slow network calls with a timeout.Promise.allSettled()runs several independent tasks and reports on all of them, successes and failures alike.Promise.any()resolves as soon as any one of the given Promises succeeds, which is useful for fallback strategies across multiple regions or sources.
// Timeout a network request after 5 seconds
const data = await Promise.race([
fetch('https://api.example.com/data').then(r => r.json()),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), 5000)
)
]);
The third scenario is building a bridge around legacy callback-based APIs — for example Node's fs.readFile or an older SDK — which requires explicitly constructing a new Promise():
function readConfigFile(path) {
return new Promise((resolve, reject) => {
legacyLib.readFile(path, (err, data) => {
if (err) return reject(err);
resolve(data);
});
});
}
8. Summary Comparison
The Verdict
async/await is not just cosmetic syntax layered over Promises.
Even though it still relies on Promises under the hood to represent completion, it reshapes asynchronous code from a chain of callback registrations into something closer to an interruptible coroutine. That change is what gives you meaningful stack traces, ordinary language-level error handling via try/catch, and straightforward lexical scoping.
Getting comfortable with modern asynchronous JavaScript ultimately comes down to recognizing when the readable, linear style of async/await suits your sequential logic, and when you should reach for raw Promise combinators — Promise.all, Promise.allSettled, Promise.race — to manage concurrency effectively.