Most people describe Node.js as "JavaScript on the server" and stop there — as if the only new idea is where the code runs. That framing misses the point and is why so many Node apps end up slow, tangled in callbacks, or mysteriously freezing under load.
Node.js isn't just JavaScript moved to the backend. It's a single-threaded, event-driven runtime built around non-blocking I/O — and almost everything that surprises people about it follows from that one design choice. Understand the event loop and the rest of Node stops being mysterious.
 |
| Node.js — Event-Driven, Non-Blocking, Single-Threaded |
The below topics are covered in this blog -
1. What Node.js actually is
2. Client-side vs server-side
3. Why Node.js — non-blocking I/O
4. The event loop: how one thread handles many requests
5. Installing Node.js (and why to use nvm)
6. A first server
7. Scheduling: setTimeout, setInterval, setImmediate, process.nextTick
8. Callbacks
9. Promises
10. async / await
11. Express
12. Common Mistakes
13. The Takeaway
1. What Node.js actually is
Node.js is a runtime that lets JavaScript run outside the browser, created by Ryan Dahl in 2009. It runs on top of Google's V8 engine (the same one in Chrome) and adds what a server needs that a browser doesn't — a file system, networking, processes, and streams.
The defining feature is its concurrency model: asynchronous, event-driven, non-blocking I/O. When Node hits an I/O operation — a database query, a file read, a network call — it doesn't sit and wait. It starts the operation, moves on to other work, and comes back when the result is ready. That's what lets a single Node process handle thousands of concurrent connections.
2. Client-side vs server-side
Client-side code runs in the user's browser — HTML, CSS, and the JavaScript behind frameworks like Angular, React, and Vue. It renders the interface and reacts to the user, but it can't talk to a database or hold shared state between users.
Server-side code runs on a machine you control. It owns the database, authentication, business logic, and anything that must be trusted or shared. Node.js sits here alongside Java, .NET, PHP, Go, and Python — with the distinction that the language is the same JavaScript your front end already uses, so one team can work across the whole stack.
3. Why Node.js — non-blocking I/O
Picture a restaurant with one waiter. In a blocking model, the waiter takes table 1's order, walks it to the kitchen, and then stands there until the food is cooked before serving anyone else. Table 2 waits the whole time. In a non-blocking model, the waiter takes table 1's order, hands it to the kitchen, and immediately goes to table 2 — picking food up whenever a kitchen is done. One waiter, many tables served.
Node is the second waiter. The same file read, written two ways:
const fs = require("fs");
// BLOCKING - nothing else runs until the file is read
const data = fs.readFileSync("big.txt", "utf8");
console.log(data);
console.log("this waits");
// NON-BLOCKING - Node continues, callback fires when ready
fs.readFile("big.txt", "utf8", (err, data) => {
if (err) throw err;
console.log(data);
});
console.log("this runs immediately, before the file is read");
4. The event loop: how one thread handles many requests
Node runs your JavaScript on a single thread. When a request needs I/O, Node hands that work off (to the operating system or to libuv's background thread pool) and keeps processing other requests. When the I/O finishes, its callback is placed on a queue, and the event loop — which is constantly watching that queue — runs it. This is how one thread stays busy instead of blocked.
A crucial nuance the old "Node is single-threaded" slogan misses: the event loop is single-threaded, but Node is not helpless at parallelism. I/O uses a background thread pool, and for genuinely CPU-heavy work you can spin up worker_threads. The rule of thumb: I/O-bound work is where Node shines; CPU-bound work needs care so it doesn't block the loop.
 |
| Node.js — one thread, an event queue, and the loop that drains it |
5. Installing Node.js (and why to use nvm)
You can download Node directly from nodejs.org (Windows / macOS / Linux). But the better first move is a version manager, so you can switch Node versions per project without reinstalling:
# macOS / Linux - nvm
nvm install --lts
nvm use --lts
node -v
npm -v
# Windows - nvm-windows (github.com/coreybutler/nvm-windows)
nvm install lts
nvm use lts
Stick to the current LTS ("Long Term Support") release for anything real — it's the line that receives stability and security fixes.
6. A first server
Node ships with an HTTP module, so a working server needs no dependencies at all:
const http = require("http");
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello from Node.js");
});
server.listen(3000, () => console.log("Listening on http://localhost:3000"));
One note on module syntax: the require() style above is CommonJS, still everywhere in Node. Modern Node also supports ES modules — import http from "node:http" — when your package.json sets "type": "module". New projects increasingly use ESM.
7. Scheduling: setTimeout, setInterval, setImmediate, process.nextTick
Node gives you several ways to defer work, and they run at different points in the loop. Getting the ordering wrong is a classic source of "why did this log first?" confusion.
| Function |
When it runs |
Repeats? |
Typical use |
| process.nextTick() | Before the loop continues, after the current operation | No | Run something right after current code, before any I/O |
| queueMicrotask() | Microtask queue, after nextTick | No | Promise-style deferral without a full timer |
| Promise .then() | Microtask queue | No | Async result handling |
| setTimeout(fn, 0) | Timers phase, next loop iteration | No | Defer by a minimum delay |
| setInterval(fn, ms) | Timers phase, every ms | Yes | Recurring work — clear it when done |
| setImmediate() | Check phase, after I/O callbacks | No | Run right after the current I/O cycle |
console.log("start");
setTimeout(() => console.log("setTimeout"), 0);
setImmediate(() => console.log("setImmediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
console.log("end");
// typical order:
// start, end, nextTick, promise, setTimeout, setImmediate
The lesson isn't to memorize the order — it's that nextTick and microtasks jump ahead of timers, and abusing process.nextTick() can starve the loop.
8. Callbacks
A callback is a function you pass to another function to be run when its work finishes — the original way Node expressed "do this, then that." The convention is error-first: the first argument is the error (or null), the second is the result.
fs.readFile("data.json", "utf8", (err, data) => {
if (err) return console.error("failed:", err);
console.log("got:", data);
});
Callbacks work, but nesting several of them — read a file, then query a DB, then call an API — produces the deeply indented "callback hell" that's hard to read and easy to get wrong. That pain is exactly what Promises were introduced to fix.
9. Promises
A Promise represents a value that will exist eventually. Instead of nesting, you chain — and errors flow to a single .catch() instead of being checked at every step.
fs.promises.readFile("data.json", "utf8")
.then((data) => JSON.parse(data))
.then((obj) => console.log(obj.name))
.catch((err) => console.error("failed:", err));
// run several in parallel and wait for all
Promise.all([fetchUser(), fetchOrders(), fetchCart()])
.then(([user, orders, cart]) => render(user, orders, cart));
10. async / await
Now standard JavaScript, async/await lets you write Promise-based code that reads like sequential code, with ordinary try/catch for errors. This is the idiom to reach for in new code.
async function loadProfile(id) {
try {
const user = await fetchUser(id);
const orders = await fetchOrders(id); // sequential
return { user, orders };
} catch (err) {
console.error("loadProfile failed:", err);
throw err;
}
}
// independent calls? run them together, don't await one at a time
async function loadDashboard(id) {
const [user, orders, cart] = await Promise.all([
fetchUser(id), fetchOrders(id), fetchCart(id)
]);
return { user, orders, cart };
}
A historical note for anyone reading older tutorials: you'll see the async npm library and its waterfall, parallel, race, and priorityQueue helpers. It predates native async/await. Today Promise.all replaces parallel, Promise.race replaces race, and sequential await replaces waterfall — you rarely need the library anymore.
11. Express
The built-in HTTP module is low-level. Express is the long-standing minimal framework that adds routing, middleware, and request/response conveniences on top — free, open source, and still the most common way to build APIs in Node.
const express = require("express");
const app = express();
app.use(express.json()); // parse JSON bodies (middleware)
app.get("/health", (req, res) => res.json({ ok: true }));
app.post("/users", (req, res) => {
const user = req.body;
res.status(201).json({ id: 1, ...user });
});
app.listen(3000, () => console.log("API on http://localhost:3000"));
Express is the default, not the only option. Fastify (faster, schema-first), Koa (from the Express team, async-first), and NestJS (opinionated, TypeScript-first) are worth knowing as you scale.
12. Common Mistakes
1. Blocking the event loop. A single synchronous CPU-heavy task — a big JSON.parse, a tight loop, readFileSync in a request handler — freezes every connection while it runs. Offload heavy work to worker_threads or a separate service.
2. Swallowing errors in callbacks. Error-first callbacks only help if you actually check the first argument. Ignoring err hides failures until they surface as something worse downstream.
3. Unhandled promise rejections. An async function that rejects with no catch can crash the process on modern Node. Always handle rejections, or wrap awaited calls in try/catch.
4. Awaiting independent calls one at a time. Three awaits in a row that don't depend on each other run sequentially and triple your latency. Use Promise.all.
5. Expecting Node to parallelize CPU work for free. "Non-blocking" is about I/O, not computation. Node won't magically use all your cores for a number-crunching loop — that's what worker threads or clustering are for.
13. The Takeaway
Everything distinctive about Node.js traces back to one idea: a single-threaded event loop that never blocks on I/O. Lean into that — asynchronous code, parallel awaits, non-blocking calls — and Node scales beautifully for the I/O-bound work most web services actually do. Fight it with synchronous CPU work on the main thread, and you'll wonder why one slow request stalled everything. Design with the loop, not against it.
About the author
I'm Atique Ahmed, Principal AI Architect — 7x Microsoft MVP and a Guinness World Record holder for Programming Excellence. I write about GenAI, agentic AI, and the systems that hold real applications together.
Find more at atiqueahmed.com · LinkedIn · GitHub