Skip to main content
Featured image for blog post: One Thread, Two Queues: How JavaScript Decides What Runs Next
William Craig
William Craig
July 26, 2026 · 12 min read

One Thread, Two Queues: How JavaScript Decides What Runs Next

Most of us now review more JavaScript than we write. A model produces forty lines, they look right, the tests pass, and it ships.

Which is fine, until the bug is about order. Generated code is very good at being shaped correctly and subtly wrong about when things run. A value read after an await that something else already changed. An await sitting inside a loop, quietly turning twenty parallel requests into twenty sequential ones. A cleanup that fires one tick too late. None of that looks like a bug. It reads beautifully, it has no red squiggle, and no test catches it unless you already suspected it was there.

There is one reliable way to spot that class of mistake, and it is knowing how the runtime decides what runs next. Not roughly. Exactly.

Happily, the machine that makes those decisions is small: one thread, two queues, and a single rule about which queue wins. That is most of it. It is one of the few parts of this platform you can genuinely finish learning, and once you have it, it stops being only about async. The same model tells you why one heavy loop freezes an entire page, where browser APIs like timers and fetch actually plug into the language, what an await really costs, why a promise that already holds its value still makes you wait, and where in all of this the browser finds a gap to paint.

So this article does not describe the machine, it hands it to you. There is a button below that freezes the page you are reading, four runtimes you can step through one instruction at a time, and a promise pulled apart down to the internal slots the specification actually gives it.

Everything below runs in your browser, right now, as you scroll. If you doubt a claim, press it.

One thread. Here is the proof.

Everyone repeats that JavaScript is single threaded. Almost nobody makes you feel it.

Below are two moving bars. The green one is moved by your JavaScript, one step per requestAnimationFrame. The amber one is a plain CSS transform animation. There is also a text field. Type into it, then hit the red button and keep typing.

Live, in this page

Freeze the thread and watch what dies

requestAnimationFrame · driven by your JavaScript0 frames drawn
CSS transform · driven by the compositor
Block for

This really does lock the page. Scrolling, clicking and typing all stop until the loop is done.

The CSS bar usually keeps sliding, because most browsers run transform animations on a separate compositor thread. That is the same reason a janky page can still look like it is animating while none of your code can run.

The green bar stops dead. The frame counter stops counting. Your keystrokes do not vanish, they queue up somewhere and land all at once when the loop finishes. And the amber bar, in most browsers, sails right past it, because a transform animation is handed to a separate compositor thread that does not care that your code is stuck.

That last detail is worth keeping. "The main thread" and "the browser" are not the same thing. A page can look alive while none of your code can run. This is exactly why a frozen app still shows a spinning loader, and why that loader is lying to you.

That busy loop is the whole problem in miniature. There is one thread that runs your code, it runs one thing at a time, and it does not stop until that thing returns. Everything else in this article exists to answer one question: what happens to the work that shows up while the thread is busy?

Where callbacks actually wait

Three things are competing in this snippet, and they finish in an order that surprises people the first time.

Step through it. The machine below shows every push and pop. Use the buttons, drag the slider, or click the panel and use the arrow keys.

Three places a callback can wait

Step 1 of 16
1console.log('A');
2setTimeout(() => console.log('B'), 0);
3Promise.resolve().then(() => console.log('C'));
4console.log('D');

Nothing has run yet. Your entire file is a single job sitting in the task queue.

Console
empty
The thread
empty
Off the threadbrowser territory
empty
Gate open. The thread is idle, so the event loop may hand something over.
Microtask queuedrained first, completely
empty
Task queueone per turn, after that
run script

Four things worth pulling out of that.

Your whole file is a task. Script evaluation is not special. It is a job the event loop picked up, and until it returns, nothing else gets a turn. That is why every callback in this example runs after console.log('D'), even the one whose timer expired ages ago.

setTimeout is not JavaScript. Look at the spec for the language and you will not find it. It is a function the browser lends you. Calling it hands a callback to the browser and returns immediately. The waiting happens somewhere your thread is not.

Zero milliseconds is a request, not a promise. setTimeout(fn, 0) means "put this in the task queue as soon as the timer fires". It does not mean soon. It means after the current job, after every microtask, and after whatever else was already queued.

The two queues are not equal. The microtask queue is drained completely. The task queue gives up one job and then the loop checks the microtasks again. That asymmetry is the single most useful thing to know about this whole machine.

A promise is an object, not an event

Here is where most explanations get vague, and where it stops being intimidating once you see it.

A promise is not a subscription, or a listener, or a magic value that arrives later. It is an ordinary object with a handful of internal slots that your code cannot reach. The specification writes them in double brackets: [[PromiseState]], [[PromiseResult]], [[PromiseFulfillReactions]], [[PromiseRejectReactions]], [[PromiseIsHandled]].

That is the entire data structure. Two fields for the outcome, two lists of things to do about it, one flag.

Step through this one with the promise panel open and watch the slots fill in.

What a promise actually is

Step 1 of 15
1const p = new Promise((resolve) => {
2 setTimeout(() => resolve('done'), 100);
3});
4
5p.then(value => console.log(value));

A promise is an object with internal slots your code can never touch. Here they are.

Console
empty
The threadbusy
run script
Off the threadbrowser territory
empty
Gate shut. The thread is busy, so every queue is frozen.
Microtask queuedrained first, completely
empty
Task queueone per turn, after that
empty
Promise objects
empty

Two moments in there are worth slowing down on.

The executor, the function you pass to new Promise, runs immediately and synchronously. It is not deferred, not queued, not async in any way. new Promise(...) calls it before the constructor returns. Whatever asynchrony you get comes from what you put inside it, usually a browser API like a timer or fetch.

And .then does two separate jobs that are easy to blur together. It builds a reaction record, a small object holding your callback, and it returns a new promise. If the promise it was called on is still pending, the reaction is parked inside that promise's [[PromiseFulfillReactions]] list. If the promise has already settled, there is nothing to wait for, so the reaction goes straight to the microtask queue.

Either way, calling resolve() never calls your handler. It writes two slots and moves any parked reactions into the queue. Running them is a separate trip.

What a chain costs

Because .then returns a new promise, you can chain. Because each link is its own promise, each link is its own trip through the microtask queue.

What a chain costs

Step 1 of 14
1Promise.resolve(1)
2 .then(n => n * 2)
3 .then(n => n * 2)
4 .then(n => console.log(n));

Promise.resolve(1) hands back a promise that is already fulfilled with 1.

Console
empty
The threadbusy
run script
Off the threadbrowser territory
empty
Gate shut. The thread is busy, so every queue is frozen.
Microtask queuedrained first, completely
empty
Task queueone per turn, after that
empty
Promise objects
P1
[[PromiseState]]fulfilled
[[PromiseResult]]1
[[PromiseIsHandled]]false
[[PromiseFulfillReactions]]
[]

Three .then calls, three separate visits to the queue. Notice that all three reaction records are created synchronously, in one pass, before a single callback runs. Two of them sit parked inside promises that have no value yet.

This is not a performance warning. A microtask is genuinely cheap, and if you are chaining twelve of them you have bigger design questions than the queue. It is a mental model correction: a chain is not one deferred computation, it is N promises each waking the loop up once.

async / await is the same machine with better syntax. Every await suspends the function, registers a reaction on the awaited promise, and resumes in a microtask. An async function with four awaits in it makes four trips. Which is why this returns to the caller before logging anything:

await null still queues a microtask, so outside prints first. There is no such thing as an await that costs nothing.

The rule that actually matters

Almost everyone learns "microtasks have priority over tasks" and stops there. The precise version is more useful:

The microtask queue is drained to empty after every single task, not just at the end of the script. And any microtask that queues another microtask gets drained in the same pass.

Which means a microtask loop can starve the browser completely, while the identical loop written with setTimeout cannot. Rendering is a step in the event loop, and that step does not come until the microtask queue is empty.

Run both. Same loop, same time budget, opposite outcomes.

Live, in this page

Same loop, two queues, opposite outcomes

queueMicrotask

callbacks run
frames painted
elapsed

not run yet

setTimeout(fn, 0)

callbacks run
frames painted
elapsed

not run yet

Both runs are capped at 1.2 seconds. Notice the callback counts too: the task version is far slower per callback, because browsers clamp nested setTimeout to about 4ms after a few levels.

The callback counts are the interesting part. On my machine the microtask version runs about two million callbacks and paints one frame. The task version runs about 260 and paints seventy. Same budget, same loop body.

Two separate things are doing that. The microtask version starves the render step, because the browser never reaches it. The task version is slow per callback on purpose: browsers clamp nested setTimeout to roughly 4ms after about five levels, which is exactly the 4.6ms per callback those numbers work out to.

If you ever need to chew through a lot of work without freezing the page, that is the trade you are making. Chunk it into tasks and give the browser a chance to draw between them. Or move it to a worker, which is the only way to get an actual second thread.

Now guess

Two snippets. Pick before you scroll.

Guess first

What order do these log in?

1new Promise((resolve) => {
2 console.log(1);
3 resolve(2);
4}).then(result => console.log(result));
5
6console.log(3);

Here is that one stepped out, if the answer felt like a trick:

The one that catches people

Step 1 of 13
1new Promise((resolve) => {
2 console.log(1);
3 resolve(2);
4}).then(result => console.log(result));
5
6console.log(3);

Object created, state pending.

Console
empty
The threadbusy
run script
new Promise(executor)
Off the threadbrowser territory
empty
Gate shut. The thread is busy, so every queue is frozen.
Microtask queuedrained first, completely
empty
Task queueone per turn, after that
empty
Promise objects
p
[[PromiseState]]pending
[[PromiseResult]]undefined
[[PromiseIsHandled]]false
[[PromiseFulfillReactions]]
[]

resolve(2) runs on line 3. The log of that value comes last. Nothing in the code moved, only your model of when handlers run.

Second one, and this is the one that catches people who already consider themselves comfortable with all of this:

Guess first

What order do these log in?

1setTimeout(() => {
2 console.log('t1');
3 Promise.resolve().then(() => console.log('m1'));
4}, 0);
5
6setTimeout(() => console.log('t2'), 0);

What to do with this

Four things I actually use this model for.

Reading race conditions. When two callbacks fire in an order you did not expect, the question is almost never "which one is faster". It is "which queue is each one in". A promise callback and a timer callback are not competing on speed, they are competing on category, and the promise wins every time.

Spotting frozen UI before it ships. Any synchronous loop over a big array, any large JSON.parse, any layout thrash in a tight loop is exactly the red button above. If it takes 200ms, the page is dead for 200ms. There is no partial credit.

Knowing that await is a yield point. State can change between the line before an await and the line after it. Other code ran in the gap. That is the source of a lot of subtle bugs in code that looks perfectly sequential.

Not trusting setTimeout(fn, 0). It is not "run this next". It is "run this after everything currently queued, plus every microtask those produce, plus a clamp I do not control".

Where to read more

The mechanism is not folklore, it is written down. The queues and the render step live in the HTML specification's event loop processing model. The promise slots and reaction records are in ECMA-262, section 27.2. Both are drier than any blog post, and both will settle an argument in a way a blog post cannot.

One caveat before you go: everything above is the browser event loop. Node.js runs a different one, with phases, plus process.nextTick, which cuts ahead of even the microtask queue, and no render step at all, because there is nothing to render. The promise half of this article carries over unchanged. The task queue half does not.

Everything else, you can just press.


Tags

  • JavaScript
  • Event Loop
  • Promises
  • Web Development
  • Interactive