DEV Community

BysonTech
BysonTech

Posted on

How JavaScript Asynchronous Processing Actually Works

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:

  1. setTimeout() is called
  2. The timer is registered with the browser
  3. JavaScript immediately continues executing
  4. 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:

  1. Execute all synchronous code
  2. Execute all Microtasks
  3. Execute one Task
  4. Execute any newly created Microtasks
  5. 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:

  1. Wait for fetchA()
  2. 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)