The Event Loop Explained
The event loop is the engine that lets one Node.js process handle thousands of visitors at once. It sounds technical, but the idea is simple. Node.js runs your JavaScript on a single thread, so it can literally do one thing at a time. The trick is that most work is not done by JavaScript at all.
When your code asks to read a file, query a database, or wait on a timer, Node.js hands that job to the operating system and moves on to the next task. The event loop keeps circling, checking whether any of those finished jobs are ready. When one is, it runs the callback you attached to it. That is the whole machine: start work, keep moving, run callbacks when work completes.
This is why synchronous code is dangerous. A synchronous file read or a long loop runs directly on the main thread and blocks everything behind it. Every other visitor waits, timers stall, and the server appears frozen. Keeping slow work asynchronous is the single most important habit for Node.js code.
One thread, many jobs
The event loop never stops, and it never runs your callbacks at the same time. Work that is slow, like file I/O, happens in the background through the operating system. When it finishes, the callback is queued, and the loop runs it on the next pass. That is how one thread seems to do many things at once.
setTimeout(() => {
console.log("timer done"); // runs later, after the loop frees up
}, 1000);
console.log("first"); // runs immediately
// output: first, then timer done
What blocks the event loop
Anything that runs synchronously on the main thread blocks the loop: fs.readFileSync, a huge JSON.parse, or a loop that counts to a billion. While it runs, no timers fire and no responses are sent. The fix is to use the async versions, like fs.promises, and avoid heavy CPU work on the main thread.
// This freezes every visitor for the duration:
const data = fs.readFileSync("big.txt", "utf8");
// This does not block:
const data = await fs.promises.readFile("big.txt", "utf8");
Timers and the loop
setTimeout and setInterval are handled by the loop, not by JavaScript itself. The delay is the minimum wait, not an exact time. If the main thread is busy, the callback runs late. Never assume a timer fires on time; assume it fires after the given delay at the earliest.
const start = Date.now();
setTimeout(() => {
console.log("elapsed:", Date.now() - start);
}, 500);
// will print 500 or more, never less
Syntax
const fs = require("fs");
// This blocks the event loop while the disk works:
// const data = fs.readFileSync("notes.txt", "utf8");
// This lets the loop keep serving other requests:
fs.promises.readFile("notes.txt", "utf8").then((data) => {
console.log(data);
});
console.log("loop is still free"); // runs before the file is read
setTimeout 0 Still Waits for I/O
setTimeout 0 runs after I/O, not immediately — event loop phases matter.
setTimeout(()=>console.log(2),0); fs.readFile(()=>console.log(1)) // 1 then 2
Good to know
- The event loop runs one JavaScript task at a time on a single thread.
- Slow work is handed to the operating system and picked up later.
- Synchronous file reads and long loops block every other request.
- Timer callbacks run at the earliest possible time, never exactly on time.