Async Code and the Event Loop
At the Queue Terminal, thousands of requests wait in line and one worker serves them all without ever standing still. That is how Node.js works: your JavaScript runs on a single thread, and it stays fast because it never sits idle waiting for a database or a network call. Most async bugs beginners ship come from a wrong picture of what "waiting" means, so this lesson builds the right one.
Why Node doesn't block on I/O
Reading a file, querying Postgres or calling an API takes milliseconds to seconds, which is forever for a CPU. Instead of waiting, Node hands that work to the operating system and moves on. When the result is ready, a callback is put in a queue, and the event loop runs it as soon as the thread is free.
console.log('1');
fs.readFile('notes.txt', () => console.log('2'));
console.log('3');
// prints 1, 3, 2
The catch: this only helps with I/O. A long synchronous loop, or a sync function like bcrypt.hashSync on a big cost factor, keeps the one thread busy, and every other request waits behind it.
Callbacks, promises and async/await
These are three ways of writing the same idea: "run this when the result arrives." Callbacks nest badly and make error handling easy to forget. A promise is an object standing for a future value, which later resolves (success) or rejects (error). async/await is a nicer syntax on top of promises: code reads top to bottom, and errors can be caught with ordinary try/catch.
async function getProfile(id) {
const user = await db.findUser(id); // pauses getProfile only
const posts = await db.findPosts(user.id);
return { user, posts };
}
What await actually pauses
await pauses the function it is in, not the program. While getProfile waits for the database, Node is free to handle other requests, run timers and finish other queries. When the result arrives, getProfile picks up where it stopped. An async function always returns a promise, even if you return a plain value.
This is also why other code can run "in the middle" of your function at every await, which matters a lot in the next mission.
Parallel with Promise.all, sequential with a loop
// Slow: each request starts only after the previous one finished
for (const url of urls) results.push(await fetch(url));
// Fast: start them all, then wait for all of them
const results = await Promise.all(urls.map((url) => fetch(url)));
Ten calls of 300ms each take about three seconds in the loop and about 300ms with Promise.all. Use the loop only when a step needs the result of the one before, such as creating an order and then charging using its id. Promise.all rejects as soon as any one promise rejects, so you get that error and not the other results. Use Promise.allSettled when you want every outcome, successes and failures.
The forgotten await
Leave out await and you get a promise instead of a value, with no error to warn you.
const allowed = canEdit(user, doc); // async function, missing await
if (allowed) saveDoc(doc); // a Promise is always truthy
Even if canEdit resolves to false, the check passes, because any object is truthy. The same mistake in res.send(await getData()) versus res.send(getData()) sends {} to the client. ESLint's @typescript-eslint/no-floating-promises and no-misused-promises rules catch many of these before you ship.
Unhandled rejections
A promise that rejects with nobody awaiting it has no try/catch to land in. In current Node versions an unhandled rejection crashes the whole process by default, taking every in-flight request down with it.
try {
sendWelcomeEmail(user); // not awaited: the catch below never sees its error
} catch (err) { log(err); }
If you really want fire-and-forget, attach the handler to the promise itself: sendWelcomeEmail(user).catch(log). Otherwise, await it inside the try.
Resources
Curated resources for this node are on the way. Use what you already know how to search for, and check back soon.