synchronous JavaScript lets long-running tasks (like network requests, timers, and disk I/O)
run without blocking the single-threaded main thread, keeping apps responsive while work
completes in the background.
Core idea
• JavaScript runs on a single thread with a call stack, so blocking operations would
freeze UI or delay other code if executed synchronously.
• Asynchronous APIs schedule callbacks to run later via the event loop, which pulls
work from queues (tasks/macrotasks and microtasks) when the call stack becomes
empty.
Event loop, tasks, microtasks
• A “task” (macrotask) is a unit like a timer, user input, or network callback; after a task
completes, the engine drains the microtask queue before running the next task.
• Microtasks (e.g., promise reactions) have higher priority than macrotasks; they run to
completion after each task, before any new task is selected.
• Ordering example: synchronous logs run first, then promise .then microtasks, then
setTimeout callbacks (even with 0 ms), producing “Start → End → Promise →
Timeout”.
Callback pattern
• Early async JS used callbacks passed into APIs such as setTimeout(fn, ms) or XHR;
callbacks execute later when the event loop schedules them.
• Callbacks can nest deeply (“callback hell”), making control flow and error handling
difficult to reason about for complex sequences.
Example — callback-based timer:
js
console.log("A");
setTimeout(() => {
console.log("B after 0ms");
}, 0);
console.log("C");
Expected order: A, C, then B, because the timeout callback is a macrotask scheduled after
current code and any pending microtasks.
Promises
• A Promise is a proxy for a value that may not be known yet, representing states:
pending → fulfilled or rejected; handlers attach via then/catch/finally and run when
settled.
• Promises flatten nested callbacks and enable composition (chaining and concurrency
helpers like all, race, allSettled, any) for clearer async control flow.
Example — wrapping a callback API:
js
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
delay(500).then(() => console.log("Half a second passed"));
The Promise executor schedules resolution via setTimeout, and the .then handler runs as a
microtask after the resolving task completes.
Promise chaining
• Returning a value in then passes it to the next then; returning a promise in then waits
for that promise, enabling sequential async steps without nesting.
• Errors thrown in then or rejections propagate to the nearest catch, centralizing error
handling for chains.
Example — sequential steps:
js
fetch("/api/user")
.then(r => {
if (!r.ok) throw new Error(r.status);
return r.json();
})
.then(user => fetch(`/api/projects?user=${user.id}`))
.then(r => r.json())
.then(projects => console.log(projects))
.catch(err => console.error("Failed:", err));
This uses promise composition to run HTTP requests in sequence with a single catch at the
end for any failure in the chain.
Concurrency with Promise.all
• Promise.all runs multiple promises in parallel and fulfills with an array when all
succeed, or rejects immediately if any input promise rejects.
• Use when tasks are independent but results are needed together, improving total
latency by starting them simultaneously.
Example — parallel fetches:
js
const urls = ["/a.json", "/b.json", "/c.json"];
Promise.all(urls.map(u => fetch(u).then(r => r.json())))
.then(([a, b, c]) => console.log(a, b, c))
.catch(err => console.error("One failed:", err));
This fires all requests at once and aggregates results in the original order upon fulfillment, or
bails on first rejection.
async/await
• Marking a function async makes it return a promise; within it, await pauses the
function until the awaited promise settles, resuming with its value or throwing its
error.
• async/await makes promise code read like synchronous code while preserving non-
blocking behavior, with try/catch for straightforward error handling.
Example — sequential with await:
js
async function loadUserProjects() {
const r1 = await fetch("/api/user");
if (!r1.ok) throw new Error(r1.status);
const user = await r1.json();
const r2 = await fetch(`/api/projects?user=${user.id}`);
if (!r2.ok) throw new Error(r2.status);
const projects = await r2.json();
return projects;
loadUserProjects().then(console.log).catch(console.error);
The function yields at each await without blocking the event loop, and returns a promise
that fulfills with projects or rejects on error.
Parallel with await
• For independent operations, start them first, then await both, avoiding serial waits
that waste time.
• This pattern mirrors Promise.all ergonomically while keeping explicit variables for
readability and error grouping if needed.
Example — start then await:
js
async function loadAll() {
const aP = fetch("/a.json");
const bP = fetch("/b.json");
const [a, b] = await Promise.all([aP, bP]);
return Promise.all([a.json(), b.json()]);
Both network requests run in parallel; Promise.all coordinates fulfillment and propagates
any single rejection.
Microtasks vs macrotasks in practice
• Promise callbacks (.then/.catch/finally) queue microtasks, which run before timers or
I/O callbacks, affecting observable ordering in logs and UI updates.
• queueMicrotask allows scheduling a microtask directly, useful for post-state-update
reactions that must run before the next task.
Example — ordering demo:
js
console.log("start");
setTimeout(() => console.log("timeout 0"), 0);
Promise.resolve().then(() => console.log("microtask"));
queueMicrotask(() => console.log("queued microtask"));
console.log("end");
Order: start, end, microtask, queued microtask, timeout 0, because microtasks flush before
the next macrotask.
Structured async iteration
• for await...of iterates over async iterables (e.g., streams, paginated APIs), awaiting
each produced value, simplifying consumption of asynchronous data sources.
• This pattern aligns with fetch streaming and Node.js async generators, offering
backpressure-friendly iteration with clear syntax.
Example — async generator and consumption:
js
async function* paginate(url) {
let next = url;
while (next) {
const r = await fetch(next);
if (!r.ok) throw new Error(r.status);
const { items, nextPage } = await r.json();
yield items;
next = nextPage;
for await (const page of paginate("/api/items?page=1")) {
console.log("got page", page.length);
}
Each await pauses within the loop without blocking the thread, fetching pages lazily as
needed.
Error handling patterns
• Try/catch inside async functions catches awaited rejections; outside, always attach a
catch to the returned promise to avoid unhandled rejections.
• For multiple parallel awaits, wrap with Promise.all and catch once, or handle results
individually using Promise.allSettled to inspect each outcome safely.
Example — allSettled:
js
const results = await Promise.allSettled(urls.map(u => fetch(u)));
for (const r of results) {
if (r.status === "fulfilled") console.log("OK");
else console.warn("Failed:", r.reason);
allSettled never short-circuits, returning an array of outcome objects for robust bulk
operations.
Practical guidelines
• Prefer promises/async-await over raw callbacks for readability, composability, and
error handling, except when interfacing with legacy APIs that force callbacks.
• Use concurrency deliberately: parallelize independent work, but sequence
dependent steps; keep in mind microtask priority when orchestrating UI updates and
logs.
Mini-exercises
• Rewrite a nested callback sequence (timer → fetch → parse) into promise chains,
then into async/await, verifying identical behavior with console logs to observe
ordering.
• Add both Promise.resolve().then(...) and setTimeout(..., 0) to a snippet to predict and
confirm the execution order based on microtask vs macrotask queues.
Advanced notes
• The execution model specifies job queues and priorities; different environments
(browsers vs Node) may vary in task categorization, but microtasks still run before
the next task.
• Newer syntax like await using and async disposables integrate cleanup patterns with
async code, improving resource safety across awaits and loops.
Key references for deeper study: MDN’s Async JS module, Promise guides, async function
reference, execution model, microtask guides, and Promise combinators