One thread, two queues
JavaScript on a page runs on one thread, the same thread that handles input and painting. Asynchronous code does not mean two functions running at the same instant. It means the thread can start some work, go do something else, and come back when the result is ready.
The runtime keeps two relevant queues. A task (timers, I/O callbacks, user
events) is picked up one at a time. After that task finishes, the runtime drains
the microtask queue completely, including microtasks that were queued by
other microtasks, and only then considers rendering or the next task. Promise
reactions (.then, .catch, .finally, and the code after an await) are
microtasks. setTimeout schedules a task. People often call tasks “macrotasks”.
The spec’s word is task.
That single rule predicts this program:
console.log('start')
setTimeout(() => console.log('timeout'), 0)
Promise.resolve()
.then(() => console.log('promise 1'))
.then(() => console.log('promise 2'))
console.log('end')
The log is start, end, promise 1, promise 2, timeout.
start and end run in the current task. The timeout is only queued, and a
delay of 0 means “a future timer task”, after the current script and any
microtasks. The first .then is queued as a microtask. When the script ends,
that microtask runs and logs promise 1. Its own .then queues promise 2,
and the microtask checkpoint runs that too before the timer task is allowed to
start.
async / await is the same mechanism in different syntax. An async function
always returns a promise. await pauses the function and resumes it as a
microtask.
async function run() {
console.log('1')
await null
console.log('2')
}
run()
console.log('3')
// 1, then 3, then 2
1 is synchronous, up to the first await. 3 is the rest of the current
task. 2 is the continuation, queued like a .then. Jake Archibald’s
Tasks, microtasks, queues and schedules
is still the clearest walk through why this order exists. MDN’s
microtask guide
covers the same checkpoint from the spec side.
If you need work to happen after the current script but before the next timer or
paint, queueMicrotask schedules it directly. Most application code should not.
A microtask that queues more microtasks forever will not yield to rendering.
From callbacks to promises
A callback hands the continuation to someone else: “when you are done, call
this.” Node’s older APIs used an error-first callback, (error, value) => {}.
The shape has two practical problems. Errors and values travel on different
conventions, so it is easy to forget the error check. And a sequence of
dependent steps nests, because each step has to be written inside the previous
callback.
A promise is an object that stands for a value that is not available yet. It is
pending, and then it settles: fulfilled with a value, or rejected with a reason.
Settling happens once. Reactions attached afterward still run, as microtasks,
which is why a promise that is already resolved still delivers .then
asynchronously.
function delay(ms, value) {
return new Promise((resolve) => {
setTimeout(() => resolve(value), ms)
})
}
delay(20, 'done').then((value) => console.log(value))
The executor passed to new Promise runs immediately. resolve fulfills the
promise. reject, or a throw inside the executor, rejects it. What you return
from .then becomes the fulfillment value of the next promise. What you throw,
or return as a rejected promise, sends the chain to the next .catch.
async / await is syntax for that chain. await on a non-promise wraps it,
which is why await null still yields. try / catch around an await
handles the rejection. MDN’s
Using promises
is the reference for the settlement rules, including this one: a promise
returned from a .then and then forgotten is a floating promise. Its rejection
becomes an unhandled rejection. The same thing happens when you call an async
function and neither await it nor return it.
async function save(draft) {
await fetch('/api/drafts', {
method: 'POST',
body: JSON.stringify(draft),
})
}
// The rejection has nowhere to go.
save(draft)
// This one is tracked.
await save(draft)
fetch fails in two different ways
fetch rejects when the network itself fails: the user is offline, the request
is aborted, DNS never resolves. It fulfills for an HTTP error. A 404 and a 500
are successful fetches of an error response, and .catch will not see them.
async function getJson(url, {signal} = {}) {
const response = await fetch(url, {signal})
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
return response.json()
}
response.ok is true for statuses 200 through 299. Throwing here turns an HTTP
error into a rejection, so the try / catch at the call site handles both
kinds of failure. Read the body only after that check. response.json() is
itself asynchronous: the headers arrive first, and the body is a stream.
Dependent steps, independent steps
await in sequence means each step waits for the previous one. Use it when the
second call needs something the first call returns.
const user = await getJson('/api/me')
const posts = await getJson(`/api/users/${user.id}/posts`)
When the calls do not need each other, waiting in series just adds the latencies
together. Promise.all starts them together and fulfills with the results in
the order of the input array, which is not the order they finished.
const [user, settings] = await Promise.all([
getJson('/api/me'),
getJson('/api/settings'),
])
If any input rejects, Promise.all rejects. The other requests keep running,
but their values are discarded. That is the right outcome when the page cannot
use a partial result. When it can, Promise.allSettled waits for every input to
settle and reports each one:
const results = await Promise.allSettled([
getJson('/api/profile'),
getJson('/api/recommendations'),
])
for (const result of results) {
if (result.status === 'fulfilled') {
console.log(result.value)
} else {
console.error(result.reason)
}
}
The result array lines up with the input array. A fulfilled entry has value. A
rejected entry has reason.
Promise.any fulfills with the first fulfillment and ignores earlier
rejections. It rejects with an AggregateError only when every input rejects.
That fits a race between two mirrors of the same file. Promise.race settles
with whichever input settles first, success or failure.
Promise.all does not cancel the losers when it rejects. If those requests
should stop, you have to abort them yourself.
Cancellation
A promise has no standard way to be cancelled. The operation underneath
sometimes does. AbortController is that mechanism for fetch.
const controller = new AbortController()
try {
const data = await getJson('/api/search?q=rx', {signal: controller.signal})
console.log(data)
} catch (error) {
if (error.name !== 'AbortError') throw error
}
controller.abort()
Calling abort() rejects the fetch with a DOMException named AbortError. In
a search box, the controller from the previous query should be aborted before
the next query starts, otherwise a slow response can land last and paint stale
results. AbortSignal.timeout(5000) aborts on its own after five seconds, and
AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]) aborts when
either signal does.
Promise.race against a timer does not do this. The race settles, and the fetch
continues in the background, still holding the connection and still able to
throw an unhandled rejection if nobody is listening. Abort the request if the
goal is to stop the work.
The main thread still has to stay free
Concurrency on the page is the event loop interleaving tasks. Parallelism is two pieces of JavaScript executing at the same instant, and the main thread does not offer it. A tight loop over a large array blocks clicks and painting until it returns, no matter how many promises are pending, because those promises can run only after the loop’s task ends.
A Worker is a separate thread. It receives messages, computes, and posts
messages back. Those messages arrive on the main thread as tasks, so the page
stays responsive while the heavy work happens elsewhere. Workers do not see the
DOM. They are for CPU work, not for awaiting fetch, which was already off the
main thread inside the browser.
The practical split is: I/O waits go through promises, long calculations go to a worker or get broken into smaller tasks, and ordinary UI code stays on the main thread and avoids doing either by accident.
Habits that prevent the usual bugs
- Await the promise, or return it. A call you do not track is a rejection you will not catch.
- Treat
response.okas part of parsing.fetchwill not throw for a 500. - Use a sequential
awaitwhen a step needs the previous result. UsePromise.allwhen it does not. - Use
Promise.allSettledwhen one failed call should not hide the others. - Abort the previous request when a newer one makes it irrelevant.
- Keep the
try/catchnext to theawaitthat can fail, and rethrow anything that is not the case you actually handle.
Streams of events over time, where each value can cancel the previous request, are the point at which a promise chain gets awkward. That problem is what Reactive programming in RxJS is for. Most application code never needs to go there. It needs the queue order above, and the six habits, applied on purpose.