Learnitweb

Asynchronous task priorities and task queue

In the previous lessons, we explored different ways to write asynchronous code in JavaScript, such as:

  • Using callbacks
  • Working with Promises
  • Using async / await
  • Scheduling work using setTimeout

All these approaches help us deal with tasks that do not complete immediately, such as API calls, timers, or user interactions.

However, an important question often remains unanswered:

In what order are these asynchronous operations actually executed?

More specifically:

  • Does JavaScript treat setTimeout and Promise callbacks the same way?
  • Do they have the same priority?
  • Why do some asynchronous operations run earlier even if they appear later in the code?

To answer these questions, we need to look deeper into how the JavaScript runtime, event loop, and task queues work together.

A Simple Starting Example

Let us begin with a very simple example that contains three statements:

console.log(1);

setTimeout(() => {
  console.log(2);
}, 0);

console.log(3);

At first glance, this code may look straightforward. You might think:

  • First, console.log(1) runs.
  • Then setTimeout runs after 0 milliseconds.
  • Then console.log(3) runs.

So maybe the output should be:

1
2
3

But that is not what happens.

Actual Output

1
3
2

Let’s understand why this happens.

Why setTimeout(..., 0) Does Not Run Immediately

Even though we specify 0 milliseconds in setTimeout, it does not mean “run immediately”.

What it really means is:

“Execute this callback after the current execution stack is empty and once the task is picked from the task queue.”

Key difference between the two statements:

  • console.log(1) is executed immediately because it goes directly into the call stack.
  • setTimeout schedules its callback and sends it to the task queue, even if the delay is 0.

Because of this behavior:

  • The call stack must completely finish execution first.
  • Only after that can the event loop move tasks from the queue into the stack.

That is why the order becomes:

1 → 3 → 2

Replacing setTimeout with a Promise

Now let’s slightly modify the example by replacing setTimeout with a Promise.

console.log(1);

Promise.resolve().then(() => {
  console.log(2);
});

console.log(3);

Output:

1
3
2

The output remains the same — but the reason is different.

Why does this happen?

  • The synchronous statements (console.log(1) and console.log(3)) are executed immediately.
  • The promise callback is asynchronous, so it does not execute right away.

However, the promise callback is not placed in the same queue as setTimeout.

This brings us to an extremely important concept.


Not All Asynchronous Tasks Are Equal

Even though both setTimeout and Promise are asynchronous, they do not have the same priority.

JavaScript internally uses different queues for different types of tasks.

Broadly speaking:

  • Promises go into the microtask queue
  • setTimeout / setInterval go into the macrotask (task) queue

And microtasks always have higher priority than macrotasks.

This is why promises usually execute before setTimeout, even if the timeout appears earlier in the code.

Combining setTimeout and Promise

Now let’s examine a slightly more complex example where both appear together.

console.log(1);

setTimeout(() => {
  console.log(2);
}, 0);

Promise.resolve().then(() => {
  console.log(3);
});

console.log(4);

Step-by-step execution:

  1. console.log(1) runs immediately.
  2. setTimeout is registered and its callback is placed in the task queue.
  3. The promise callback is placed in the microtask queue.
  4. console.log(4) executes immediately.
  5. The call stack is now empty.
  6. The event loop checks the microtask queue first, so console.log(3) runs.
  7. Only after all microtasks are finished does the event loop process the task queue, executing console.log(2).

Final Output:

1
4
3
2

Why Does This Happen?

This happens because JavaScript assigns different priorities to different types of asynchronous work.

  • Microtasks (Promises, MutationObserver)
    These have higher priority and are executed immediately after the current call stack is empty.
  • Macrotasks (setTimeout, setInterval, I/O events)
    These run only after all microtasks are completed.

This priority system ensures predictable and efficient execution of asynchronous code.

Revisiting the Event Loop (A Deeper View)

Earlier, you learned that JavaScript relies on:

  • The Call Stack
  • The Task Queue
  • The Event Loop

However, this is a simplified view.

In reality, modern browsers have multiple task queues, not just one.

Multiple Task Queues in Browsers

Browsers may maintain separate queues for different types of tasks, such as:

  • Timers (setTimeout, setInterval)
  • User interaction events (clicks, keyboard input)
  • Network operations (HTTP responses)
  • Microtasks (Promises)

Each browser is free to implement this differently.
The ECMAScript specification only states that:

A browser can have one or more task queues, and tasks within the same queue must be executed in order.

This means:

  • Tasks within the same queue always preserve order.
  • Tasks across different queues can be interleaved depending on priority.

How the Event Loop Chooses Tasks

The event loop operates roughly as follows:

  1. Check if the call stack is empty.
  2. If yes, pick the next available task from a queue.
  3. Execute it completely.
  4. Process all microtasks before moving on.
  5. Repeat.

Important rule:

Tasks from the same queue must be executed in the order they were added.

This guarantees predictability within each task category.

Rendering and the Browser’s Rendering Pipeline

So far, we’ve discussed JavaScript execution. But browsers also need to render the UI.

Rendering happens:

  • Roughly 60 times per second
  • Which means once every ~16 milliseconds

This frequency ensures smooth animations and responsive interactions.

However:

  • Rendering can only happen when the call stack is empty
  • If JavaScript blocks the thread for more than ~16ms, the browser cannot repaint

This leads to:

  • Janky animations
  • UI freezing
  • Poor user experience

Why Long Tasks Are Dangerous

If a single task runs longer than 16ms:

  • The rendering pipeline must wait.
  • Frames are dropped.
  • Animations stutter.
  • User interactions feel unresponsive.

This is why modern applications try to:

  • Break heavy tasks into smaller chunks
  • Use requestAnimationFrame
  • Offload work to Web Workers when possible

Summary

  • JavaScript executes code using a call stack, task queues, and an event loop.
  • setTimeout and Promise callbacks are asynchronous, but they have different priorities.
  • Promises (microtasks) always run before timers (macrotasks).
  • Browsers may have multiple task queues, and tasks in the same queue preserve order.
  • Rendering happens roughly every 16ms, but only when the main thread is free.
  • Long-running JavaScript blocks rendering and causes performance issues.