Node.js for Beginners: How It Works and When to Use It
Node.js is not just another way to write JavaScript on the server. Its architecture is fundamentally different from traditional server-side runtimes — and understanding that architecture is what separates developers who use Node.js effectively from those who fight it.
This guide explains how Node.js works, walks you through building your first API, and helps you understand when it is the right choice and when it is not.
How Node.js Actually Works
Single-Threaded, Non-Blocking
Node.js runs on a single thread. Unlike Java or .NET, it does not spin up a new thread for each incoming request. Instead, it uses an event-driven, non-blocking I/O model built around the event loop.
When Node.js receives a request that requires I/O — a database query, a file read, an HTTP call — it does not wait. It registers a callback, continues processing other work, and executes the callback when the I/O completes.
This is why Node.js handles thousands of concurrent connections efficiently. The bottleneck is not threads — it is I/O latency. And because Node.js does not block on I/O, it can keep processing while waiting.
What This Means in Practice
// WRONG: This blocks the event loop
const data = fs.readFileSync('./large-file.json'); // Blocks everything
processData(data);
// RIGHT: Non-blocking
fs.readFile('./large-file.json', (err, data) => {
if (err) throw err;
processData(data);
});
The synchronous version (readFileSync) blocks the entire Node.js process while the file is read. Every other incoming request waits. The asynchronous version registers the callback and moves on immediately.
Async Patterns in Modern Node.js
Node.js has evolved through three generations of async patterns. Modern code uses async/await almost exclusively.
Callbacks (legacy, avoid in new code)
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
// Deeply nested — "callback hell"
});
});
Promises
getUser(userId)
.then((user) => getOrders(user.id))
.then((orders) => processOrders(orders))
.catch((err) => handleError(err));
Async/Await (current standard)
async function processUserOrders(userId) {
try {
const user = await getUser(userId);
const orders = await getOrders(user.id);
return processOrders(orders);
} catch (err) {
handleError(err);
}
}
Async/await is syntactic sugar over Promises. Under the hood it is the same — but the code reads like synchronous logic, which makes it far easier to reason about and debug.
Building Your First REST API
Node.js has no built-in HTTP routing. The most common starting framework is Express.js.
mkdir my-api && cd my-api
npm init -y
npm install express
// index.js
const express = require('express');
const app = express();
app.use(express.json()); // Parse JSON request bodies
// In-memory data store (for learning — use a database in production)
const users = [
{ id: 1, name: 'Arivu', role: 'admin' },
{ id: 2, name: 'Priya', role: 'editor' },
];
// GET all users
app.get('/users', (req, res) => {
res.json(users);
});
// GET user by ID
app.get('/users/:id', (req, res) => {
const user = users.find((u) => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
// POST create user
app.post('/users', (req, res) => {
const { name, role } = req.body;
if (!name || !role) {
return res.status(400).json({ error: 'name and role are required' });
}
const newUser = { id: users.length + 1, name, role };
users.push(newUser);
res.status(201).json(newUser);
});
app.listen(3000, () => {
console.log('API running on http://localhost:3000');
});
Run it:
node index.js
Test it:
curl http://localhost:3000/users
curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"name":"Kumar","role":"developer"}'
npm and the Module System
Node.js uses npm (Node Package Manager) to manage dependencies. Your package.json defines what your project depends on.
Key commands:
npm install <package> # Add a dependency
npm install <package> --save-dev # Add a dev-only dependency
npm install # Install all dependencies from package.json
npm run <script> # Run a script defined in package.json
Common packages you will use from day one:
- express — HTTP framework
- dotenv — environment variable management
- axios — HTTP client for external API calls
- nodemon — auto-restart on file changes during development
When to Use Node.js
Node.js is well-suited for:
- REST and GraphQL APIs — lightweight, high-throughput, JSON-native
- Real-time applications — chat, live dashboards, notifications (WebSockets)
- Microservices — small, focused services communicating over HTTP or message queues
- Scripting and automation — build scripts, data processing pipelines, CLI tools
- BFF (Backend for Frontend) — lightweight API gateway layer between frontend and backend services
Node.js is not the right tool for:
- CPU-intensive computation — image processing, video encoding, cryptographic operations. These block the event loop and degrade all concurrent requests. Use worker threads or a different runtime.
- Applications requiring complex multithreading — Node.js's concurrency model is powerful for I/O but not for CPU parallelism
The single-threaded event loop is not a limitation if you understand it. It is a constraint that, respected, produces exceptionally efficient systems.