Phase 1
Asynchronous and Non-Blocking I/O
How Node.js starts slow work, such as reading a file, and keeps doing other things while it waits.
The problem with waiting
A database round trip takes 20 milliseconds. Not long - until you're handling a thousand requests at once and every one of them wants that same 20 milliseconds of nothing happening. In a traditional blockingBlockingA function call that stops all other work until it finishes - like a phone call where you wait in silence for an answer. The opposite of non-blocking, where you start the work and move on immediately. model, the thread handling that request just sits there for the duration, doing no other work, serving nobody else.
Node doesn't do that. It starts the database call, and instead of waiting, it moves on to the next thing that needs handling. When the result comes back, it picks the work back up. That's the entire idea behind non-blocking I/O - not that the work happens faster, but that nothing sits idle waiting for it.
What libuv is actually doing
Node itself doesn't implement any of this. It hands the work to libuv, a C library doing the actual plumbing underneath.
Call fs.readFile() and libuv:
- Hands the disk read off to the operating system (or, for a handful of operation types, to its own thread pool - more on that below)
- Registers a callback with the event loopEvent loopThe mechanism that lets a single-threaded runtime like Node.js handle many operations at once by never sitting idle - it starts slow work, moves on, and comes back when a result is ready.
- Gives control back to your JavaScript immediately, before the read has finished
When the OS says the read is done, libuv queues the callback, and the event loop runs it on its next poll phase. You never touch libuv directly - you just write fs.readFile() with a callback, or await it, and this is what's actually happening underneath either way.
Callbacks, promises, async/await - same mechanism, different handwriting
// Callbacks - the original style
fs.readFile("./data.json", "utf8", (err, data) => {
if (err) throw err;
console.log(data);
});
// Promises
fs.promises.readFile("./data.json", "utf8")
.then(data => console.log(data))
.catch(err => console.error(err));
// async/await - reads like synchronous code, isn't
async function loadData() {
const data = await fs.promises.readFile("./data.json", "utf8");
console.log(data);
}All three compile down to the same libuv mechanism underneath. async/await doesn't change what happens at runtime - it's syntax sugar over promises, nothing more. Pick whichever reads clearest for the situation; there's no performance difference between them.
Not everything is actually non-blocking
Here's the part that catches people off guard: some operations - certain filesystem calls, DNSDNSThe system that turns a human-readable hostname into the numeric IP address computers actually use to route traffic. The internet's phonebook, distributed across a chain of servers rather than kept in one place. lookups, a handful of crypto functions - genuinely cannot be made non-blocking at the OS level. For those, libuv keeps a small thread pool (four threads by default) and hands the blocking work off there instead.
What actually happens, in order
const data = await fs.promises.readFile("bigfile.json", "utf8");
doSomethingWith(data);- Node asks libuv to start the read
- libuv either hands it to the OS directly, or to the thread pool
- Your JavaScript keeps running - other requests get handled in the meantime
- The OS reports the read is done
- libuv queues the callback
- The event loop picks it up, and execution resumes right after the
await
The JavaScript thread was never blocked at any point in that sequence. It was doing other work the entire time the file was being read.
Side by side, the two versions of the same file read differ by one keyword and a method name. What differs underneath is who owns the thread while the disk is busy:
Where the thread is released
jsThe blocking version, read normally
Three lines, no callbacks, and the value is right there on line 3. This is genuinely the easier code to read, which is most of why it keeps getting written.
Where this goes wrong
fs.readFileSync() inside a request handler is the most common mistake - it looks harmless on a dev machine with one user, and quietly becomes a production incident once real traffic shows up, because it blocks the exact thread every other request is also waiting on.
Unhandled promise rejections are the second one - an error inside an async function with no catch doesn't crash loudly, it just disappears, and you find out about it later from a support ticket instead of a stack trace.
And it's easy to assume two sequential awaits are somehow linked, when they're just two separate async operations running one after the other for no reason:
// Sequential - waits for userQuery to finish before starting postsQuery
const user = await getUserById(id);
const posts = await getPostsByUser(id);
// Parallel - both queries run at the same time
const [user, posts] = await Promise.all([getUserById(id), getPostsByUser(id)]);If the two calls don't depend on each other, Promise.all() is free performance sitting right there.