A program spends most of its life waiting: for a click, a file, a server on the other side of the world. If it froze while it waited, every web page would lock up each time it loaded an image.
JavaScript solves this with one thread, a call stack, some queues and a loop. Learn the handful of rules that loop follows and you can predict, exactly, the order any async program runs in. Scroll, and the machine runs each program as you read it.
Every code block runs in your browser. Edit it, break it, press Run.
01 · BLOCKING
Blocking: when the thread is stuck
JavaScript in a web page runs on one thread: one call stack, doing one thing at a time. The same thread also handles your clicks and draws the page. So if your code is busy, nothing else can happen. That is blocking.
Run this and watch the timer. It asked for 0 ms. It gets about 1000, because the thread was stuck in a loop and could not even look at the timer:
On the stage, the tall red frame is sleepBlocking. The clicks pile up in the queue and the screen freezes, because rendering needs the same thread. Try changing 1000 to 2000.
The fix is never to wait by spinning. You start the slow thing, hand over a function to call when it is done, and let the thread go. That is non-blocking:
non-blocking.js
12345678
console.log("1. order a coffee");
setTimeout(()=>{
console.log("4. coffee is ready");
},1000);
console.log("2. pay");
console.log("3. find a seat");
Like ordering at a café: you don't stand frozen at the counter until the coffee exists. You get on with paying and finding a seat, and they call your name.
16.7 msis one frame at 60 frames a second. Any task longer than that can make a page skip a frame; tasks over 50 ms are what browsers call "long tasks". web.dev: RAIL
02 · CALLBACKS
Callbacks: call me when it's done
A callback is a function you pass to someone else, to be called later. You have already used one: the arrow function handed to setTimeout. Here is a café that takes a callback, in the Node style where the first argument is an error (or null):
callback.js
123456789101112131415
// A callback is just a function you hand over,
// to be called later, when the result exists.
functionmakeCoffee(order,done){
console.log("brewing a",order);
setTimeout(()=>{
done(null,order+" (hot)");
},300);
}
makeCoffee("flat white",(err,cup)=>{
if(err)returnconsole.log("failed:",err);
console.log("got a",cup);
});
console.log("meanwhile: reading the news");
Press Step through. The step mode records every line, so you can see makeCoffee return before the coffee exists, then the callback run later on a fresh stack.
Follow it on the stage. setTimeout does not wait. It hands the timer to the Web APIs, the browser's own machinery written in C++, and returns at once. The timer counts down outside JavaScript. When it fires, the browser puts your callback in the task queue, and the loop runs it once the stack is empty. The same happens for network requests (the dish; see how the internet moves your packets) and for clicks (the mouse).
Callbacks work, but when each step needs the last one's result, they nest:
pyramid.js
1234567891011121314151617
functionstep(name,ms,done){
setTimeout(()=>{
console.log(name);
done();
},ms);
}
// Each step can only start inside the last one's callback:
step("grind beans",100,()=>{
step("heat water",100,()=>{
step("brew",100,()=>{
step("pour",100,()=>{
console.log("done: the pyramid of doom");
});
});
});
});
The "pyramid of doom". Now add error handling to every level. Promises were invented to flatten this.
03 · THE EVENT LOOP
The event loop, in 3D
Here is the whole machine. Each part has one job:
Call stack: the functions running right now. JavaScript only ever runs what is on top.
Web APIs: timers, network, input. They work outside JavaScript, often on other threads.
Task queue (amber): callbacks from timers, events and messages, waiting their turn. Browsers keep several task queues and may pick between them, but each runs in order.
Microtask queue (pink): promise callbacks and queueMicrotask. Urgent, small jobs.
Rendering: requestAnimationFrame callbacks, then style, layout and paint.
The event loop is the ring in the middle. The HTML standard spells out what it does on every turn, and it fits in three lines:
Take one task from a task queue and run it until the stack is empty.
Run every microtask, including ones queued while doing so, until the queue is empty.
If it is time for a new frame (about every 16.7 ms at 60 Hz), render.
Then go round again, or sleep until something arrives. A task is never interrupted: once code starts, it runs to completion. You never have to worry that another callback will change your variables halfway through a function. That guarantee is why single-threaded JavaScript is so much easier to reason about than threads.
04 · ORDERING PUZZLES
setTimeout ordering puzzles
You now know enough to predict any ordering. Before you press Run, write down what this prints:
puzzle-1.js
123456789
console.log("A");
setTimeout(()=>console.log("B"),0);
Promise.resolve().then(()=>console.log("C"));
queueMicrotask(()=>console.log("D"));
console.log("E");
Then watch the stage: A and E print as the script runs. B is a task. C and D are microtasks, so they run as soon as the script (itself a task) finishes, before any other task.
Answer: A E C D B. Now a harder one, where callbacks schedule more callbacks. Predict it on paper first:
puzzle-2.js
123456789101112131415
console.log("1");
setTimeout(()=>{
console.log("2");
Promise.resolve().then(()=>console.log("3"));
},0);
Promise.resolve().then(()=>{
console.log("4");
setTimeout(()=>console.log("5"),0);
});
setTimeout(()=>console.log("6"),0);
console.log("7");
It prints 1 7 4 2 3 6 5. 1 and 7 are synchronous. The script ends, so microtasks run: 4, which sets a third timer. Then the tasks, in the order they were queued: the timer for 2 (and its microtask, 3, runs straight after it, before the next task), then 6, then 5, which was set last.
The microtask rule has a sharp edge. The loop must empty the microtask queue before it does anything else, so microtasks that keep queueing microtasks starve everything: timers, clicks, even rendering.
starvation.js
1234567891011
setTimeout(()=>console.log("timer: finally my turn"),0);
// Each microtask queues another one. The loop must
// empty the microtask queue before ANY task runs.
letn=0;
functionagain(){
n++;
if(n<100000)queueMicrotask(again);
elseconsole.log("microtasks run:",n);
}
queueMicrotask(again);
Change 100000 to Infinity and the timer never fires: the sandbox stops it after 3 seconds. In a real tab the page would hang.
4 msis the minimum delay once timers are nested more than five deep: browsers clamp them, so a chain of setTimeout(fn, 0) can't spin faster. Timers with the same delay always run in the order they were set. HTML Standard: timers
05 · PROMISES
Promises: a box for a future value
A promise is an object that stands for a result that isn't there yet. It is in one of three states: pending, then either fulfilled with a value or rejected with a reason. It settles once, and never changes after that.
promise.js
12345678910
constorder=newPromise((resolve,reject)=>{
console.log("1. the executor runs at once");
setTimeout(()=>resolve("flat white"),500);
});
console.log("2. we got a promise back, still pending");
order.then((coffee)=>{
console.log("3. fulfilled with:",coffee);
});
The function you pass to new Promise (the executor) runs immediately. then doesn't run anything: it stores your callback on the promise. When the promise settles, each stored callback is queued as a microtask.
On the stage, the promise sits on the purple shelf, pending, holding one callback. When the timer task calls resolve, it turns green, and its callback drops into the microtask queue.
The real power is chaining. then returns a new promise for whatever your callback returns. Return a value and the next step gets it; return a promise and the chain waits for it:
.then((n)=>wait(300,n+1))// ...or a promise: the chain waits
.then((n)=>console.log("result:",n));
console.log("the chain is set up; nothing has run yet");
2 × 10 = 20, then 20 + 1 = 21, after two waits. Flat, not nested, and one .catch at the end would handle a failure anywhere.
06 · ASYNC / AWAIT
async / await: pausing without blocking
async and await (ES2017) let you write promise code that reads like ordinary code. An async function always returns a promise. await pauses that function until a promise settles, and gives you its value.
The word "pause" hides the clever part. Predict the order, then run it:
await-order.js
12345678910
asyncfunctionbrew(){
console.log("B: brew starts, synchronously");
awaitnull;// pause here, give the thread back
console.log("D: resumed, as a microtask");
return"coffee";
}
console.log("A: before");
brew().then((x)=>console.log("E: got",x));
console.log("C: after: brew is paused, we are not");
A B C D E. brew runs synchronously right up to await. Then its frame comes off the stack and is saved, and the caller carries on. The rest of brew runs later, as a microtask.
Watch the stage: at await, the brew frame leaves the tower and parks on the shelf, while the script prints C. The thread is never blocked; only the function is paused. It comes back with all its local variables, exactly where it left off.
The commonest mistake with await is making things wait for each other when they don't need to:
serial-vs-together.js
123456789101112131415161718
constwait=(ms,v)=>
newPromise((r)=>setTimeout(()=>r(v),ms));
asyncfunctiononeAfterAnother(){
constt=Date.now();
consta=awaitwait(300,"a");
constb=awaitwait(300,"b");
console.log("one after another:",Date.now()-t,"ms");
console.log("at the same time:",Date.now()-t,"ms");
}
awaitoneAfterAnother();
awaitatTheSameTime();
Two 300 ms waits take 600 ms one after another, and 300 ms when started together. Start everything first, then await.
Python has the same model, with the loop made explicit (asyncio):
brew.py · Python asyncio
importasyncioasyncdefbrew(name,secs):print(name,"start")awaitasyncio.sleep(secs)# give the loop backprint(name,"done")asyncdefmain():awaitasyncio.gather(brew("A",0.3),brew("B",0.1))asyncio.run(main())# A start# B start# B done# A done
1 tickis what await on a native promise costs today. It used to cost three microtask turns, until a spec change the V8 team proposed in 2018 made async functions faster than hand-written promise chains. V8: faster async functions
07 · ERRORS
Errors in async code
An error thrown in a callback can't be caught by code that has already finished. This try is useless:
try-timer.js
12345678
try{
setTimeout(()=>{
thrownewError("boom");
},0);
}catch(e){
console.log("caught it?",e.message);// never runs
}
console.log("the try block finished long ago");
The callback runs in a later task, with a fresh stack. Nobody is there to catch it, so it is reported as uncaught.
Promises carry errors for you. A rejection skips every then until it reaches a catch, just like an exception jumping up the stack. And a catch that returns a value turns the chain back into success:
chain-errors.js
12345678
Promise.reject(newError("burnt"))
.then(()=>console.log("skipped: the promise rejected"))
.catch((e)=>{
console.log("caught:",e.message);
return"tea instead";// recover
})
.then((x)=>console.log("carried on with",x))
.finally(()=>console.log("clean up, either way"));
On the stage, the red "rejected" state passes from promise to promise along the shelf until catch handles it and the chain turns green.
With await, a rejection is thrown at the await, so plain try/catch works again. A rejection nobody handles fires an unhandledrejection event in browsers, and by default crashes Node (since version 15):
await-errors.js
1234567891011121314151617
asyncfunctiongetUser(id){
awaitnull;// pretend to ask a server
if(id<0)thrownewError("no user "+id);
return{id,name:"Ada"};
}
asyncfunctionmain(){
try{
constu=awaitgetUser(-1);// the rejection is thrown HERE
console.log(u.name);
}catch(e){
console.log("handled:",e.message);
}
}
main();
getUser(-2);// nobody awaits it: an unhandled rejection
Forgetting to await or return a promise is the most common async bug: the error then has nowhere to go.
08 · ALL · RACE · ALLSETTLED
Promise.all, race, allSettled and any
Four built-ins combine many promises into one. Each makes a different choice about when to stop waiting. The stage feeds the same three promises into all four: A succeeds at 300 ms, B fails at 100 ms, C succeeds at 200 ms.
Change B's fail to ok and run it again. Which lines change?
Promise.all: every result, in order, or the first failure. Use it when you need all of them.
Promise.allSettled (ES2020): waits for all, never rejects, and reports each one's status. Use it for "try everything, then report".
Promise.race: the first to settle, success or failure. Handy for timeouts.
Promise.any (ES2021): the first success; it rejects with an AggregateError only if all fail. Use it for "the fastest mirror wins".
None of them cancels the losers. The other promises keep running; you have just stopped listening to them.
09 · CANCELLATION
Cancellation with AbortController
To stop the work itself, the web uses AbortController. The controller owns an AbortSignal; you pass the signal to anything that can be cancelled and call abort() when you change your mind. Your own functions can listen to the signal too:
abort.js
1234567891011121314151617181920
functionwait(ms,{signal}={}){
returnnewPromise((resolve,reject)=>{
signal?.throwIfAborted();
constid=setTimeout(()=>resolve("done"),ms);
signal?.addEventListener("abort",()=>{
clearTimeout(id);// stop the real work too
reject(signal.reason);
},{once:true});
});
}
constcontroller=newAbortController();
setTimeout(()=>controller.abort(),200);// the user clicks Cancel
try{
awaitwait(1000,{signal:controller.signal});
console.log("finished");
}catch(e){
console.log("stopped:",e.name);
}
On the stage, the abort task fires the signal: the red wire cuts the 1000 ms timer out of the Web APIs, the promise rejects, and the parked script resumes in its catch.
The same signal works with fetch, event listeners and many Node APIs. A rejected fetch gives you an AbortError, which usually means "we cancelled this on purpose", not a real failure:
search.js · in a page (the sandbox has no network)
constcontroller=newAbortController();cancelButton.onclick=()=>controller.abort();try{constres=awaitfetch("/api/search?q=cat",{signal:controller.signal,});show(awaitres.json());}catch(e){if(e.name==="AbortError")return;// we cancelled it: finethrowe;}// or give up on its own after 5 seconds:awaitfetch(url,{signal:AbortSignal.timeout(5000)});
Cancel when the user navigates away, types a new search, or a deadline passes. Otherwise stale answers arrive late and overwrite fresh ones.
10 · NOT PARALLEL
Why async isn't parallel
Async is concurrency: many jobs in progress at once, taking turns on one thread. It is not parallelism: many jobs running at the same instant on different cores. The difference shows the moment the work is computation instead of waiting:
console.log("two crunches:",Date.now()-t,"ms");// no overlap
Two waits overlap (about 300 ms), because the waiting happens in the network or a timer, outside JavaScript. Two crunches don't (about 600 ms): there is only one thread to crunch on.
The stage shows all three cases on a timeline. For real parallelism in the browser you start a Web Worker: a second thread with its own stack, its own event loop and no shared variables. You talk to it by passing messages. Node has worker_threads for the same job.
worker.js · two files, in a page
// main.js: hand the heavy work to another threadconstworker=newWorker("crunch.js");worker.postMessage({upTo:50_000_000});worker.onmessage=(e)=>console.log("sum:",e.data);// the page stays responsive meanwhile// crunch.js: runs on its own thread, with its own event looponmessage=(e)=>{letsum=0;for(leti=0;i<e.data.upTo;i++)sum+=i;postMessage(sum);};
Underneath, a CPU core runs one instruction stream at a time; several cores, and several machines in a cluster, are how real parallel work gets done. Threads, races and locks are the next chapter.
11 · NODE'S LOOP
Node's event loop, briefly
Node.js uses the same model with a different engine room: libuv, a C library. Its loop goes round six phases, each with its own queue: timers (setTimeout), pending callbacks, idle/prepare (internal), poll (wait for I/O and run its callbacks), check (setImmediate) and close callbacks.
Between callbacks, Node drains two extra queues: process.nextTick first, then promise microtasks. So the familiar rules still hold:
order.js · Node.js
// order.js (CommonJS), run with: node order.jssetTimeout(()=>console.log("timeout"),0);setImmediate(()=>console.log("immediate"));process.nextTick(()=>console.log("nextTick"));Promise.resolve().then(()=>console.log("promise"));console.log("sync");// sync// nextTick// promise// timeout <- these two can swap: from the main// immediate <- module, the order is not guaranteed
From inside an I/O callback the race disappears: the loop is in poll, and check comes next, so setImmediate always wins:
io-order.js · Node.js
constfs=require("node:fs");fs.readFile(__filename,()=>{// inside an I/O callback we are in the poll phase,// so "check" (setImmediate) always comes nextsetTimeout(()=>console.log("timeout"),0);setImmediate(()=>console.log("immediate"));});// immediate// timeout
One more subtlety: Node runs an ES module (.mjs) from inside its promise-based loader, so the promise queue drains first and promise prints beforenextTick. Network I/O is non-blocking in the operating system itself (epoll on Linux, kqueue on macOS, IOCP on Windows); file system work, DNS lookups and some crypto run on a small thread pool.
4threads in libuv's pool by default (set UV_THREADPOOL_SIZE to change it). Your JavaScript still runs on one. libuv: thread pool
Only cat is shown: the earlier searches are cancelled even though they were still running. Remove the abort() line and run it a few times to see stale results win.
PRACTICE
Now you do it
Type the code yourself rather than pasting it: producing an answer builds memory that reading does not. Use the hints one at a time.
1
Predict, then prove
Without running anything, write the order this program prints, as an array of letters. Use the rules: synchronous code first, then every microtask, then the next task. Notice that an async function runs synchronously until its first await.
Then paste the puzzle into any runner on this page to see if you were right.
exercise-1.js
12
// your prediction, first letter first
constprediction=["a"];
2
Turn a callback into a promise
Write sleep(ms): it returns a promise that fulfils after ms milliseconds. Then the countdown should print 3, 2, 1 and liftoff, with a pause between each.
exercise-2.js
123456789101112
functionsleep(ms){
// return a promise that resolves after ms milliseconds
}
asyncfunctioncountdown(){
for(leti=3;i>0;i--){
console.log(i);
awaitsleep(300);
}
console.log("liftoff");
}
countdown();
3
Untangle the pyramid
Wrap getUser and getOrders so each returns a promise, then write async function report(id) that prints the same line as the pyramid, using await and one try/catch. Delete the pyramid when yours works.
exercise-3.js
1234567891011121314151617181920
// callback-style helpers (pretend they ask a server)
functiongetUser(id,cb){
setTimeout(()=>cb(null,{id,name:"Ada"}),100);
}
functiongetOrders(user,cb){
setTimeout(()=>cb(null,["tea","cake"]),100);
}
functiongetUserP(id){/* return a promise */}
functiongetOrdersP(user){/* return a promise */}
// async function report(id) { ... }
getUser(7,(err,user)=>{
if(err)returnconsole.log(err);
getOrders(user,(err,orders)=>{
if(err)returnconsole.log(err);
console.log(user.name,"ordered",orders.join(" and "));
});
});
4
Give up after a deadline
Write withTimeout(promise, ms). It settles the same way as promise, unless ms passes first: then it rejects with new Error("timeout"). The two calls below should print fast and timeout.
Finish run(script), a model of the real thing: run the script as the first task, and after every task, run microtasks until there are none left, including ones queued along the way. Then take the next task. It should print A E C D B.
exercise-6.js
1234567891011121314151617181920
constout=[];
consttasks=[];// first in, first out
constmicrotasks=[];// first in, first out
constsetTimeout0=(fn)=>tasks.push(fn);
constqueueMicro=(fn)=>microtasks.push(fn);
functionrun(script){
// your loop here
}
run(()=>{
out.push("A");
setTimeout0(()=>out.push("B"));
queueMicro(()=>{
out.push("C");
queueMicro(()=>out.push("D"));
});
out.push("E");
});
console.log(out.join(" "));
RECALL
Check yourself, from memory
Answer each one before you open it. Then come back tomorrow, and again next week, and answer them cold: retrieval spaced over days is the most reliable study method we know of. Why that works.
What does the event loop do on each turn, in order?
Why doesn't setTimeout(fn, 0) run fn straight away?
Name two ways to queue a microtask, and two sources of tasks.
What happens to an async function's frame at await?
Promise.all, allSettled, race, any: which waits for everything?
Why can't a try/catch around setTimeout catch an error thrown in its callback?
Is async code parallel?
GO FURTHER
Resources
VIDEO · FREEWhat the heck is the event loop anyway? ↗Philip Roberts, JSConf EU 2014The talk that taught a generation of web developers the call stack, Web APIs and callback queue. 26 minutes, with live animation.
VIDEO · FREEIn The Loop ↗Jake Archibald, JSConf.Asia 2018The follow-up: tasks, microtasks and the rendering steps (requestAnimationFrame, style, layout, paint), told by someone who helped write the specs.
TOOL · FREELoupe ↗Philip RobertsPaste a small program and watch it move between the stack, Web APIs and the queue. It predates promises, so it has no microtask queue.
BOOK · FREEOSTEP, ch. 33: Event-based Concurrency ↗Remzi and Andrea Arpaci-DusseauThe operating-systems view: how an event loop is built on select/poll, and why blocking calls are its one great enemy.
DOCS · FREEThe Node.js Event Loop ↗Node.js docsThe official tour of the phases, setImmediate versus setTimeout, and process.nextTick.
SOURCES
Where the facts come from
HTML Living Standard: Event loops The processing model: pick one task, perform a microtask checkpoint, then update the rendering if it is time.
HTML Living Standard: Timers Timers with equal timeouts run in the order they were set; nested timers are clamped to at least 4 ms after five levels.