JavaScript is single-threaded but it can run many I/O operations concurrently thanks to the event loop. The modern way to express that concurrency is with async and await. This lesson covers the event loop, promises, the async/await syntax, and the patterns you will use in real code.
The event loop in one minute
The JavaScript runtime has a call stack where your code runs, a web APIs area for I/O, and two queues for callbacks. When the stack is empty, the runtime pulls the next task from the microtask queue (Promise reactions), then the next task from the macrotask queue (setTimeout, setInterval, I/O). This is why a resolved promise runs before the next setTimeout.
console.log("1");
setTimeout(() => console.log("2 - macrotask"), 0);
Promise.resolve().then(() => console.log("3 - microtask"));
console.log("4");
// Output: 1 4 3 2
A Promise represents a future value
A Promise is in one of three states: pending, fulfilled, or rejected. You create one with new Promise((resolve, reject) => ...). then attaches success and failure handlers. catch is a then with only a failure handler. finally runs in either case, often for cleanup.
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
delay(100)
.then(() => console.log("after 100ms"))
.catch(err => console.error("failed:", err))
.finally(() => console.log("done"));
async and await make promises read like sync
An async function always returns a promise. Inside it, await suspends execution until the awaited promise settles. Errors thrown inside the async function become a rejected promise that you catch with try/catch.
async function getUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`status ${response.status}`);
return await response.json();
} catch (err) {
console.error("getUser failed:", err.message);
return null;
}
}
Concurrency with Promise.all and friends
Promise.all runs promises in parallel and waits for all of them. It rejects as soon as any one rejects. Promise.allSettled waits for all and never rejects, giving you the outcome of every promise. Promise.race settles as soon as the first promise settles.
async function getMany(urls) {
const responses = await Promise.all(urls.map(u => fetch(u)));
return Promise.all(responses.map(r => r.json()));
}
async function getAllOutcomes(urls) {
return Promise.allSettled(urls.map(u => fetch(u).then(r => r.json())));
}
Cancellable async work with AbortController
AbortController lets you cancel a fetch (or any other operation that supports it). Pass the signal to fetch, then call controller.abort() to cancel. The fetch promise rejects with an AbortError.
async function fetchWithTimeout(url, ms) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ms);
try {
const response = await fetch(url, { signal: controller.signal });
return await response.json();
} finally {
clearTimeout(timeout);
}
}
Summary
JavaScript is single-threaded with an event loop. Promises model future values, and async/await makes them read like synchronous code. Use Promise.all for concurrent work, Promise.allSettled when you need every outcome, and AbortController for cancellation. The next lesson covers modules, the import/export syntax, and how to organize a real project.
Promise.withResolvers and top-level await
ES2024 settled Promise.withResolvers. It returns an object with a promise and its resolve and reject functions, which is convenient when you need to resolve a promise from outside the executor. ES2022 added top-level await in modules: you can use await at the top of a module without wrapping it in an async function. The runtime pauses module evaluation until the promise settles, which is great for configuration or feature detection.
// Promise.withResolvers
function delay2(ms) {
const { promise, resolve, reject } = Promise.withResolvers();
setTimeout(() => resolve("done"), ms);
return promise;
}
// top-level await in a module
const config = await fetch("/api/config").then(r => r.json());
console.log("loaded config", config);
Error handling in async code
An async function automatically rejects its returned promise when it throws. Catch with try/catch around the await, or use .catch on the returned promise. Be careful with unhandled rejections: Node and browsers log them to the console by default and may terminate the process in future versions. Always either await the promise or attach a catch handler. For long-running background tasks, listen for the global unhandledrejection event to log them somewhere.
async function load() {
throw new Error("boom");
}
// Option 1: try/catch inside async
async function main() {
try {
await load();
} catch (err) {
console.error(err);
}
}
// Option 2: catch on the returned promise
load().catch(err => console.error(err));
Comparison with Python
Python's asyncio library plays the same role as JavaScript's async/await, but with different syntax. Python uses async def to declare a coroutine and await inside the body. JavaScript uses async function and await. Python has asyncio.gather for concurrent awaits, equivalent to JavaScript's Promise.all. Python's asyncio.CancelledError is similar to JavaScript's AbortError. The biggest practical difference is that Python's event loop is explicit: you call asyncio.run(main()) to start it. JavaScript's event loop is implicit: any async function you define will participate in the loop whenever it is called. Both languages single-thread I/O concurrency; neither can do CPU-bound parallelism without explicit threading.