Skip to content

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.

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 read
const data = readFileSync('users.json')
console.log(data) // runs only after the file is fully read
console.log('done') // runs after that

In 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.

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 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.

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, 3

Even with a 0ms timeout, '3' runs after '2' because setTimeout queues the callback for the next event loop tick.

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.

  1. 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')
  2. Run it with node and verify your prediction. (Microtasks like Promises run before macro-tasks like setTimeout.)
  3. 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/await pauses functions, not the event loop.