Node.js is JavaScript running on a server, which sounds simple until you realize it has a fundamentally different concurrency model than traditional languages. Learn how the event loop works, why callbacks exist, and what actually happens when you build your first API so you don’t spend months debugging mysterious performance issues.
My first Node.js API had a catastrophic performance issue I couldn’t figure out. A simple database query took 5 seconds to complete. Then 30 seconds. Then the entire server became unresponsive. I was convinced it was a database problem. After weeks of investigation, I discovered I’d accidentally created blocking synchronous code in a request handler — a simple `fs.readFileSync()` that processed a large file. In Node.js, that single synchronous operation blocked every other request. Thirty users waiting on one slow request meant three thousand blocked operations. That’s when I realized Node.js isn’t just JavaScript on a server — it’s a completely different concurrency model that punishes you for thinking synchronously.
The Event Loop: Node.js’s Secret and Its Gotcha
Node.js is a single-threaded, event-driven JavaScript runtime designed for I/O-heavy applications. It works by queuing operations and processing them in order using an event loop. The key benefit is efficiency: you can handle thousands of concurrent connections with minimal memory overhead because Node.js doesn’t create a thread for each connection.
But here’s the thing: single-threaded doesn’t mean synchronous. Node.js is single-threaded at the JavaScript level, but underneath, the libuv library handles asynchronous operations (file I/O, network requests, timers) in a thread pool. Your code doesn’t block; operations do.
This is how Node.js processes code:
- A request comes in
- JavaScript code executes until it hits an async operation (like a database query)
- That operation is handed off to the thread pool
- Node.js moves to the next operation (the “event loop” does this)
- When the async operation completes, a callback is added to a queue
- The event loop processes that callback
The entire point of Node.js is that your code doesn’t sit idle waiting for operations to complete. It’s non-blocking by design.
Here’s a visualization in code:
// Efficient (non-blocking)
app.get('/user/:id', async (req, res) => {
const user = await db.users.findOne({ id: req.params.id });
res.json(user);
});
// When this request handler runs:
// 1. Database query is issued
// 2. Control returns to event loop immediately
// 3. Event loop processes other requests
// 4. Database query completes
// 5. Callback executes and sends response
// Terrible (blocking)
app.get('/file', (req, res) => {
const data = fs.readFileSync('/huge/file.txt');
res.send(data);
});
// When this runs:
// 1. Entire request handler locks
// 2. File is read synchronously
// 3. Event loop is blocked
// 4. EVERY OTHER REQUEST WAITS
// 5. Only after this request finishes does the event loop process othersThe blocking version is easy to write, intuitive, and catastrophically bad for performance. It’s the biggest footgun in Node.js.
Callbacks, Promises, and Async/Await: The Evolution
Node.js had a rough history with async code. Early Node.js used callbacks, which led to “callback hell” or “pyramid of doom”:
fs.readFile('/file.txt', (err, data) => {
if (err) throw err;
db.users.find(data, (err, users) => {
if (err) throw err;
users.forEach(user => {
sendEmail(user.email, (err, result) => {
if (err) throw err;
console.log('Email sent');
});
});
});
});Each operation is nested inside the previous one’s callback. You’re drowning in indentation and error handling is scattered everywhere.
Promises improved this by letting you chain operations:
fs.promises.readFile('/file.txt')
.then(data => db.users.find(data))
.then(users => Promise.all(users.map(u => sendEmail(u.email))))
.then(() => console.log('All emails sent'))
.catch(err => console.error(err));Better. Now operations are sequential and error handling is centralized. But promises still require thinking about chains and `.then()` calls.
Async/await made it look synchronous while remaining asynchronous underneath:
async function processUsers() {
try {
const data = await fs.promises.readFile('/file.txt');
const users = await db.users.find(data);
await Promise.all(users.map(u => sendEmail(u.email)));
console.log('All emails sent');
} catch (err) {
console.error(err);
}
}
processUsers();This reads like synchronous code, but it’s fully asynchronous. Operations don’t block. It’s the best of both worlds, and it’s now the standard way to write Node.js.
Building Your First API: Express and the Reality Check
Most Node.js APIs start with Express, a minimal web framework that handles routing and middleware:
import express from 'express';
import { db } from './database.js';
const app = express();
app.use(express.json());
app.post('/users', async (req, res) => {
try {
const user = await db.users.create(req.body);
res.status(201).json(user);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.get('/users/:id', async (req, res) => {
const user = await db.users.findOne({ id: req.params.id });
if (!user) {
return res.status(404).json({ error: 'Not found' });
}
res.json(user);
});
app.listen(3000, () => console.log('Server running on port 3000'));This looks deceptively simple. You get a working API in minutes. But reality hits fast.
What Breaks When You Build a Real API
First, error handling is invisible. An unhandled promise rejection in one request handler can crash your entire server, leaving other users hanging. You have to wrap everything in try/catch or risk mysterious crashes.
Second, database connection pooling is critical but easy to screw up. Each database query needs a connection. If you create a new connection per request, you’ll run out of connections and queries will hang. You need a connection pool, which means managing connection lifecycle carefully.
Third, memory leaks are subtle. A small memory leak in one handler repeated thousands of times becomes a server crash. You might be keeping references to request objects, building up arrays without clearing them, or not closing file handles properly.
// Memory leak: results array grows forever
const results = [];
app.get('/data', async (req, res) => {
const data = await db.query('SELECT * FROM large_table');
results.push(data); // Growing forever!
res.json(data);
});Fourth, concurrency limits matter. Node.js can handle thousands of connections, but your database can’t. If you get 10,000 concurrent requests and each spawns a database query, your database gets slammed. You need to implement rate limiting, queue systems, or connection pooling to prevent cascading failures.
Fifth, timing and race conditions are real. Two requests might try to update the same resource at the same time, leading to data corruption. You need database-level constraints or optimistic locking to prevent this.
// Race condition: both requests decrement the same value
app.post('/debit/:id', async (req, res) => {
const account = await db.accounts.findOne({ id: req.params.id });
const newBalance = account.balance - req.body.amount;
await db.accounts.update({ id: req.params.id }, { balance: newBalance });
res.json({ balance: newBalance });
});
// If two requests run simultaneously, the second one doesn't see the first one's debit
// Both operate on the original balance
// Result: balance is higher than it should beWhen NOT to Use Node.js
Don’t use Node.js for CPU-intensive operations. Heavy computations, image processing, machine learning — these block the event loop and make your entire server unresponsive. If you need heavy computation, use a separate worker process or a language like Go, Rust, or Python.
Don’t use Node.js if your team prefers strongly-typed languages and traditional OOP. Node.js’s dynamic nature and callback-heavy design can feel chaotic to developers from Java or C# backgrounds. You’ll spend time fighting the language instead of solving problems.
Don’t use Node.js if you need guaranteed single-threaded consistency. Node.js can run multiple instances (behind a load balancer) but session state becomes complicated. For simple applications that need tight consistency, a single-threaded language like Python+Flask might be simpler.
Common Mistakes: Blocking Code, Ignoring Errors, and Poor Resource Management
The biggest mistake is mixing blocking and non-blocking code. A single `fs.readFileSync()` in a high-traffic endpoint can bring down your entire server. Every I/O operation must be async. No exceptions.
The second mistake is ignoring unhandled rejections. Developers skip error handling on promises thinking it’s optional. It’s not. An unhandled rejection crashes your server silently.
// Bad: unhandled rejection
db.users.findOne({ id: 123 })
.then(user => console.log(user))
// .catch() is missing! If the query fails, the process crashes
// Good: always handle errors
db.users.findOne({ id: 123 })
.then(user => console.log(user))
.catch(err => console.error('Query failed:', err));The third mistake is creating too many connections, spawning too many child processes, or accumulating resources without cleaning them up. Node.js doesn’t automatically garbage-collect everything. You have to be intentional about resource cleanup.
FAQ
Is Node.js suitable for production?
Yes. Major companies like Netflix, PayPal, and Uber run production Node.js. But production requires careful attention to error handling, resource limits, monitoring, and deployment infrastructure. It’s not plug-and-play for beginners.
How do I choose between Node.js and Python for a backend?
Node.js shines for I/O-heavy, real-time applications. Python shines for data processing, machine learning, and traditional web applications. If you’re unsure, Python is probably safer for beginners. Node.js has more footguns.
What’s the performance difference between Node.js and traditional languages?
Node.js is fast for I/O operations but slower for CPU-intensive work than Go or Rust. For typical web APIs serving requests, Node.js is more than adequate. For systems that need to process millions of events per second, Go or Rust might be better.
Do I need to understand the event loop in detail?
Yes. Understanding the event loop prevents catastrophic mistakes like blocking code and unhandled rejections. You don’t need PhD-level knowledge, but you need to understand that Node.js processes one callback at a time and never block.
What’s the best database for Node.js?
Any database works with Node.js, but async-first libraries matter. Use Prisma, Mongoose, or native drivers with promise support. Avoid synchronous libraries that block the event loop.
Can Node.js handle millions of concurrent connections?
Theoretically yes, practically no. Node.js can handle thousands of connections efficiently. Millions require heavy optimization, connection pooling, horizontal scaling, and careful resource management. It’s the “C10K problem” — getting from 10,000 to millions requires serious infrastructure.
Is callback hell still a problem?
No, not with async/await. Modern Node.js code uses async/await and avoids callback hell entirely. If you’re seeing callback pyramids in new code, that’s a sign the codebase is outdated.