When I first started learning Promises and async/await, I struggled with one question:
Why does an async function return a Promise?
The more I learned, the more I realized that understanding asynchronous JavaScript requires understanding what happens behind the scenes.
To truly understand async/await, you need to understand:
- The Call Stack
- Web APIs
- The Task Queue
- Microtasks
- The Event Loop
In this article, we'll walk through these concepts step by step and see how JavaScript handles asynchronous operations internally.
JavaScript Is Single-Threaded
JavaScript executes code on a single thread.
That means only one piece of JavaScript code can run at a time.
For example:
javascript console.log("A"); console.log("B"); console.log("C");
Output:
text A B C
JavaScript executes these statements in order.
The mechanism responsible for managing this execution is called the Call Stack.
The Call Stack
The Call Stack keeps track of which function is currently executing.
Consider:
javascript function main() { a(); } function a() { b(); } function b() { console.log("hello"); } main();
Execution order:
text main() → a() → b() → console.log()
Each function call is pushed onto the stack.
When a function finishes, it is removed from the stack.
This works perfectly for synchronous code.
But what happens when an operation takes time?
Why Doesn't setTimeout Block Execution?
Consider:
javascript console.log("start"); setTimeout(() => { console.log("timeout"); }, 0); console.log("end");
Output:
text start end timeout
Even though the timeout is set to 0 milliseconds, it runs last.
Why?
Because setTimeout() is not handled by the JavaScript engine itself.
Instead, it is provided by the browser (or runtime environment) as a Web API.
The flow looks like this:
- setTimeout() is called
- The timer is registered with the browser
- JavaScript immediately continues executing
- When the timer expires, the callback is queued
This queue is called the Task Queue.
The Task Queue and Event Loop
The Task Queue stores callbacks that are ready to run.
However, queued callbacks cannot execute immediately.
The Event Loop continuously checks:
Is the Call Stack empty?
If the answer is yes, it takes a callback from the Task Queue and pushes it onto the Call Stack.
This is why:
javascript console.log("A"); setTimeout(() => { console.log("B"); }, 0); console.log("C");
produces:
text A C B
The callback must wait until all currently executing code has finished.
Promises Are Different
Now let's look at Promises.
javascript setTimeout(() => { console.log("timeout"); }, 0); Promise.resolve().then(() => { console.log("promise"); });
Many developers expect the timeout to run first.
But the actual result is:
text promise timeout
Why?
Because Promise callbacks are not placed into the Task Queue.
They go into a separate queue called the Microtask Queue.
Microtasks vs Tasks
JavaScript maintains two different queues.
Task Queue
Examples:
- setTimeout
- setInterval
- DOM events
Microtask Queue
Examples:
- Promise.then()
- Promise.catch()
- Promise.finally()
- queueMicrotask()
The important rule is:
All Microtasks are executed before the next Task.
This rule explains much of JavaScript's asynchronous behavior.
Event Loop Execution Order
The Event Loop follows this pattern:
- Execute all synchronous code
- Execute all Microtasks
- Execute one Task
- Execute any newly created Microtasks
- Repeat
Example:
javascript console.log("start"); setTimeout(() => { console.log("timeout"); }, 0); Promise.resolve().then(() => { console.log("promise"); }); console.log("end");
Output:
text start end promise timeout
The Promise callback executes first because Microtasks have higher priority than Tasks.
What async/await Really Does
Consider:
javascript async function test() { return 1; }
This is effectively equivalent to:
javascript function test() { return Promise.resolve(1); }
An async function always returns a Promise.
How await Works Internally
Now consider:
javascript async function main() { console.log("A"); await Promise.resolve(); console.log("B"); } main(); console.log("C");
Output:
text A C B
Why?
Because await splits the function into two parts.
Conceptually, JavaScript transforms this into something similar to:
javascript Promise.resolve().then(() => { console.log("B"); });
Everything after await becomes a Microtask.
That's why it executes later.
Sequential vs Parallel Execution
A common mistake is writing:
javascript const a = await fetchA(); const b = await fetchB();
This runs sequentially:
- Wait for fetchA()
- Start fetchB()
To run them in parallel:
javascript const aPromise = fetchA(); const bPromise = fetchB(); const a = await aPromise; const b = await bPromise;
Or more commonly:
javascript const [a, b] = await Promise.all([ fetchA(), fetchB() ]);
The key insight is:
Parallelism is determined by when a Promise starts, not by await itself.
Final Thoughts
JavaScript's asynchronous model is built on five key components:
- Call Stack
- Web APIs
- Task Queue
- Microtask Queue
- Event Loop
Understanding these concepts makes it much easier to reason about:
- Why Promise callbacks run before setTimeout callbacks
- Why code after await executes later
- Why multiple awaits can become sequential
- How to run asynchronous operations in parallel
The most important takeaway is that async/await and Promises are not asynchronous processing themselves.
They are abstractions built on top of the Event Loop that control when code gets executed.
Once you understand the Event Loop, much of JavaScript's asynchronous behavior becomes predictable.
If you'd like to learn where JavaScript actually runs and how browsers execute JavaScript internally, check out my next article:
Understanding How JavaScript Runs in the Browser | V8 & DevTools
Top comments (0)