The Event Loop and Non-Blocking I/O
Node.js’s reputation for handling thousands of simultaneous connections comes from one design decision: non-blocking I/O powered by an event loop. Understanding this model removes the mystery from async Node.js code.
The problem with blocking I/O
Section titled “The problem with blocking I/O”In a blocking (synchronous) model, when your code reads a file or queries a database, execution stops and waits:
// Blocking — execution pauses here until the file is readconst data = readFileSync('users.json')console.log(data) // runs only after the file is fully readconsole.log('done') // runs after thatIn a server handling many concurrent requests, this is catastrophic. If request A is waiting for a database query, request B must wait too — even if B doesn’t need the database at all.
Non-blocking I/O
Section titled “Non-blocking I/O”In Node.js, I/O operations are non-blocking by default. You initiate the operation and provide a callback (or use a Promise). Node.js registers the callback and moves on immediately:
import { readFile } from 'fs'
readFile('users.json', 'utf8', (err, data) => { // This runs when the file is ready — could be milliseconds later console.log(data)})
console.log('This runs BEFORE the file is read')The output:
This runs BEFORE the file is read{ ... file contents ... }Node.js didn’t block waiting for the file. It registered “when the file is ready, call this function” and kept running.
The event loop
Section titled “The event loop”The event loop is the mechanism that makes this work. It runs in a continuous cycle:
Check for pending callbacks ↓Execute any ready callbacks ↓Check for I/O events (file reads, network responses) ↓Execute their callbacks ↓(repeat)When you call readFile, Node.js hands the work to the operating system and returns immediately. The OS notifies Node.js when the file is ready. The event loop picks up the notification on its next pass and calls your callback.
This is why Node.js can handle thousands of concurrent connections with a single thread: it never blocks. While waiting for a database response for request A, it’s processing request B, C, and D.
The call stack
Section titled “The call stack”JavaScript is single-threaded — only one thing runs at a time on the call stack. The event loop queues callbacks and runs them one at a time when the stack is empty.
console.log('1')
setTimeout(() => { console.log('3') // queued, runs after current execution}, 0)
console.log('2')// Output: 1, 2, 3Even with a 0ms timeout, '3' runs after '2' because setTimeout queues the callback for the next event loop tick.
Async/await is the same model
Section titled “Async/await is the same model”Modern Node.js uses Promises and async/await — the event loop is the same underneath:
async function loadUser(id: string) { const user = await db.query('SELECT * FROM users WHERE id = ?', [id]) // Execution "pauses" here but the event loop is NOT blocked // Other requests continue being processed return user}await pauses the current async function but not the event loop. Other code continues running while the database query completes.
Exercise
Section titled “Exercise”- Predict the output of this code before running it:
console.log('A')setTimeout(() => console.log('B'), 0)Promise.resolve().then(() => console.log('C'))console.log('D')
- Run it with
nodeand verify your prediction. (Microtasks like Promises run before macro-tasks likesetTimeout.) - In your own words: why can a Node.js server handle many concurrent database queries efficiently if it’s single-threaded?
- Blocking I/O stops execution until an operation completes — bad for servers.
- Non-blocking I/O registers a callback and continues — Node.js’s default.
- The event loop runs continuously, picking up completed I/O callbacks and executing them.
- Node.js is single-threaded but handles concurrency by never blocking — perfect for I/O-heavy APIs.
async/awaitpauses functions, not the event loop.