Lesson 01: Your setTimeout runs last
Lesson objectives:
- Describe the call stack as the functions JavaScript is running right now.
- State the run-to-completion rule: all the synchronous code finishes before any deferred callback runs.
- Predict that a
setTimeout(fn, 0)callback prints after the synchronous lines that come after it.Prerequisites: you can write a JavaScript function and call
console.logandsetTimeout| Next 02 >>
You wrote it in the middle, so why does it print last?
Here is the moment that makes people distrust JavaScript. You write three lines top to bottom, one of them is a setTimeout(fn, 0) sitting in the middle, and its message prints last anyway — even though you asked for zero milliseconds. It feels like the language ignored your order. It didn't. There is a precise rule underneath, and it is the foundation for everything else in this course: JavaScript has exactly one thread, it finishes all the plain synchronous code first, and anything "deferred" waits its turn. Get this one rule and the rest of the ordering puzzles become readable.
Explanation
Start with the call stack. It is just the list of functions JavaScript is in the middle of running, stacked last-in-first-out: calling a function pushes a frame on top, returning pops it off1. When you run a file, the whole script is the bottom frame, and every console.log(...) you call pushes on, runs, and pops off before the next line. There is only one of these stacks, and only one thread working through it — JavaScript does one thing at a time1.
That single thread leads straight to the rule that explains today's surprise: run-to-completion. Each job runs completely before any other job starts1. In plain terms: JavaScript will not interrupt your synchronous code to squeeze in a callback. It runs your script from top to bottom to the very end first, and only then looks at anything that was deferred.
So what does setTimeout actually do? It does not run its callback now. It hands the callback to the runtime to run later, after a delay — and a delay of 0 does not mean "immediately," it means "in a later pass, once the current work is done"2. The callback is deferred, so run-to-completion guarantees every synchronous line runs before it, no matter where in the file you wrote the setTimeout.
Read it as the engine does. console.log("A") is synchronous, so it runs and prints A. The setTimeout line runs synchronously too, but all it does is register the arrow function to run later; the arrow itself does not run yet. console.log("C") is synchronous, so it prints C. Now the synchronous script is finished and the stack is empty — only then does the deferred callback get its turn and print B. The order is A, C, B.
Notice the delay value barely matters here. Even at 0, the callback is deferred, and deferred always loses to synchronous. That is why "make it faster" by lowering the timeout never fixes an ordering bug — the callback was never going to beat the synchronous code in the first place.
Worked example (follow along)
You are timing how long a synchronous loop takes, with a log before and after and a deferred log in between:
Step by step, here is why the timer log comes last. console.log("start") is synchronous — it prints start. The setTimeout line runs and registers its callback to fire later; nothing prints from it yet. The for loop is synchronous, so it runs to completion right now, and console.log("loop done, total = 3") prints. Only after this entire script finishes and the stack is empty does the deferred callback run and print timer done. The output is start, loop done, total = 3, timer done — the timer waited for all the synchronous work, exactly as run-to-completion promises.
Your turn (faded example)
Predict the output of this snippet before you run it. Fill in the order:
Answer: the order is hi, bye, deferred. Walk it: the setTimeout line registers its callback for later and moves on. greet() is a synchronous call — it pushes onto the stack, prints hi, and pops off. console.log("bye") is synchronous, so it prints bye. The script is now done and the stack is empty, so the deferred callback finally runs and prints deferred. The setTimeout appears second in the source but prints last, because deferred work always waits for synchronous work to finish.
Summary + what's next
JavaScript has one thread and one call stack, and it obeys run-to-completion: every synchronous line finishes before any deferred callback runs. A setTimeout(fn, 0) does not run its callback now — it schedules it for later, so the callback always prints after the synchronous code, wherever you wrote it. But we glossed over one thing: where does that deferred callback wait between "registered" and "run," and who decides when to run it? That waiting room is the task queue, and the thing that pulls from it is the event loop — the subject of Lesson 02.
Footnotes
-
MDN: JavaScript execution model — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Execution_model ↩ ↩2 ↩3
-
MDN: setTimeout() — https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout ↩
Exercises
Create a file order.mjs in your own editor and paste the A / B / C snippet from the Explanation. Run it with node order.mjs (or paste it into your browser's DevTools console). Confirm the printed order before reading on.
Add a second setTimeout(() => console.log("D"), 0) after the console.log("C") line. Predict the full order before running, then run it and check.