Node.js Internals Explained by Uncle to Nephew - Part 4: Express Plumbing, Error Handling & The Full Roadmap
Part 4.1 - Two Directions Node Never Confuses
Before plumbing, one small but important idea that ties Parts 2 and 3 together. Everything Node does falls into exactly two directions.
DIRECTION 1 - Incoming Events - "The outside world is telling Node something happened"
OS โ libuv โ Event Loop โ Your JavaScript
Examples: HTTP request arrives, TCP connection opens, WebSocket message arrives
DIRECTION 2 - Outgoing Async Operations - "Your JavaScript is asking Node to go do something"
JavaScript โ libuv โ Worker Thread โ OS โ Disk/DB
โ result comes back through libuv โ Event Loop โ your callback
Examples: fs.readFile(), crypto.pbkdf2(), dns.lookup()
Nephew: So an incoming HTTP request and a fs.readFile() call both eventually pass through libuv and the event loop - but they enter from completely opposite directions?
Uncle: Exactly. One is the world pushing something at Node. The other is Node reaching out to go get something. Same event loop handles both, but the journey to get there is different - an HTTP request never touches the thread pool; a file read almost always does.
Incoming HTTP Request:
Browser โ OS โ libuv โ Event Loop โ JavaScript
File Reading:
JavaScript โ libuv โ Worker Thread โ OS โ Disk
โ
Worker Thread โ libuv โ Event Loop โ JavaScript
Nephew: That single distinction actually explains a lot of confusion I've had for years.
Uncle: It's a small diagram, but it's one of those "aha" moments once someone actually draws it for you instead of just saying "it's all async."
Part 4.2 - Body Parsing: What express.json() Actually Solves
Uncle: Now the plumbing. Here's the problem express.json() exists to solve.
Browser sends:
POST /login
Content-Type: application/json
{ "email": "suraj@gmail.com", "password": "myPass123" }
Uncle: Without any body parser:
app.post('/login', (req, res) => {
console.log(req.body); // undefined
});
Nephew: Why undefined? The data was clearly sent.
Uncle: Because the request body doesn't arrive as one neat object - it arrives as a raw stream of bytes, in chunks, exactly like we discussed with Buffers and Streams in Part 2. Nobody reads and assembles those chunks for you unless you tell Express to.
app.use(express.json());
app.post('/login', (req, res) => {
console.log(req.body); // { email: '...', password: '...' }
});
Uncle: express.json() is middleware that listens to the incoming body stream, waits for all chunks to arrive, joins them into one Buffer, parses that Buffer as JSON text, and attaches the result to req.body.
Raw byte chunks arriving
|
[chunk1][chunk2][chunk3]
|
express.json() joins + parses
|
req.body = { email: "...", password: "..." }
Nephew: And express.urlencoded() - different beast?
Uncle: Same idea, different format - it parses old-school HTML form submissions (key=value&key2=value2 style) instead of JSON.
| Middleware | Parses | Used for |
|---|---|---|
express.json() |
application/json bodies |
Modern APIs, React/fetch/axios calls |
express.urlencoded() |
application/x-www-form-urlencoded |
Classic HTML <form> submissions |
cookie-parser |
Cookie header |
Reading cookies sent with every request |
Part 4.3 - Cookies: The Header Nobody Explains Properly
Nephew: Cookies confuse me the most. What is cookie-parser actually doing?
Uncle: Every request from a browser that has cookies set automatically carries a Cookie header - plain text, semicolon-separated.
GET /dashboard HTTP/1.1
Cookie: sessionId=abc123; theme=dark
Uncle: Without cookie-parser, that's just one long unparsed string sitting in req.headers.cookie. With it:
app.use(cookieParser());
app.get('/dashboard', (req, res) => {
console.log(req.cookies);
// { sessionId: 'abc123', theme: 'dark' }
});
"sessionId=abc123; theme=dark"
|
cookie-parser splits + decodes
|
req.cookies = { sessionId: "abc123", theme: "dark" }
Nephew: So it's the exact same idea as express.json() - turning one raw header/stream into a clean object I can actually use.
Uncle: Exactly the same philosophy across Express: raw data in, structured object out, one middleware at a time.
Part 4.4 - Router: The Missing Middle Layer
Nephew: Okay, express.Router() - I use it in every project's routes/ folder but never questioned why it exists instead of just writing everything in app.js.
Uncle: Imagine putting every route of a real app directly on app:
app.get('/users', ...)
app.post('/users', ...)
app.get('/users/:id', ...)
app.post('/orders', ...)
app.get('/orders/:id', ...)
app.post('/products', ...)
// ...200 more lines in one file
Nephew: That would be an unreadable mess in any real project.
Uncle: Exactly why Router() exists - it's a mini, self-contained Express app you can build separately and plug in.
// routes/userRoutes.js
const router = require('express').Router();
router.get('/', getAllUsers);
router.post('/', createUser);
router.get('/:id', getUserById);
module.exports = router;
// app.js
const userRoutes = require('./routes/userRoutes');
app.use('/users', userRoutes);
Request: GET /users/42
|
app.use('/users', userRoutes) โ matches the prefix
|
inside userRoutes: router.get('/:id', ...) โ matches the rest
|
getUserById(req, res) runs
Nephew: So the prefix /users is stripped off before the router even looks at the path?
Uncle: Correct - from the router's point of view, it only ever sees what comes after the mount path. That's what makes routers composable - you could mount the exact same userRoutes file at /v2/users tomorrow without touching a single line inside it.
Nephew: And the difference between a Route and a Router, precisely?
Uncle: One-liner each:
| Term | What it is |
|---|---|
| Route | One single URL + method mapping - router.get('/:id', handler) |
| Router | A whole collection of related routes, bundled and mountable as one unit |
| Controller | The actual function logic a route points to, kept in a separate file |
routes/userRoutes.js (the Router, holds many Routes)
|
โโโ GET / โ controllers/userController.getAll
โโโ POST / โ controllers/userController.create
โโโ GET /:id โ controllers/userController.getById
Part 4.5 - Error Handling: Where Bugs Actually Go to Die
Nephew: Now the topic that scares me most - errors. I try/catch my async functions and hope for the best. Is that enough?
Uncle: Depends entirely on where the error happens. Let's split it properly.
- Synchronous error โ
try/catchworks directly - Async error (Promise) โ
try/catchworks ONLY if youawaitit - Error inside a callback โ
try/catcharound the callback CANNOT catch it - Uncaught anywhere โ process may crash entirely
Nephew: Wait - "try/catch around a callback can't catch it"? That sounds dangerous.
Uncle: It is, and it's one of the most common silent bugs in Node apps. Look:
try {
fs.readFile('missing.txt', (err, data) => {
if (err) throw err; // this throw happens LATER, outside the try block
});
} catch (e) {
console.log('caught it'); // never runs!
}
Uncle: By the time that callback actually fires, the try/catch around it has already finished executing and closed. The error is thrown into thin air.
try { fs.readFile(..., callback) โ try block finishes immediately, callback hasn't run yet }
catch { ... } โ this block is already "closed"
... time passes ...
callback finally runs, throws โ nothing is listening anymore
Nephew: So how do people actually catch async errors properly?
Uncle: With Promises + async/await, because await genuinely pauses your function until the result (or error) comes back - so try/catch around it works correctly.
async function loadUser(id) {
try {
const user = await db.query('SELECT * FROM users WHERE id = ?', [id]);
return user;
} catch (err) {
console.error('DB query failed:', err.message);
throw err; // let the caller decide what to do
}
}
Nephew: And in Express specifically, if a route handler throws - what happens?
Uncle: Express has a built-in concept of error middleware - a special function with four parameters instead of the usual three (req, res, next).
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong' });
});
Route handler throws / calls next(err)
|
Express skips all normal middleware
|
Jumps straight to the error-handling middleware
|
Client gets a clean error response instead of a crash
Nephew: "Skips all normal middleware" - that's a detail I never knew. So error middleware needs to sit at the very end of the chain?
Uncle: Always at the end - after all your routes. Express recognizes it purely by its four-argument signature, not by where you write it, but convention (and sanity) says: put it last.
Nephew: What about errors Express doesn't even know exist - like something in an unrelated setTimeout somewhere?
Uncle: That's where two special process-level events matter, and every production app should have both:
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Promise rejection:', reason);
// log it, alert someone, then usually shut down gracefully
});
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err);
process.exit(1); // the process is now in an unknown state - restart it
});
Nephew: Why exit the process instead of just... logging and continuing?
Uncle: Because an uncaught exception means something happened that your code was not designed to handle - the process's internal state could be anything. Continuing to serve requests from a corrupted state is far more dangerous than restarting cleanly. This is also why production Node apps always run under a process manager (PM2, Docker with a restart policy, Kubernetes) - so when the process does exit, something immediately brings it back up.
Uncaught exception
|
Log it, alert someone
|
process.exit(1)
|
PM2 / Docker / Kubernetes notices the process died
|
Automatically restarts it
|
Service is back within seconds, not permanently down
Part 4.6 - The Honest "What Actually Fails" Section
Nephew: You mentioned way back - what genuinely happens if someone floods an unprotected endpoint? Not deep, just the honest picture.
Uncle: Fair, quick and honest, no exaggeration needed.
- Normal: requests in โ requests handled โ smooth, fast
- Getting bad: requests in > requests handled โ queue grows, response times climb
- Getting worse: memory used to hold queued/pending requests keeps growing โ memory pressure increases, GC runs more often, pauses grow
- Breaking point: event loop is constantly busy (long queue + GC pauses) โ the process becomes unresponsive or crashes
Nephew: So it's never really a sudden explosion - it's a ramp?
Uncle: Almost always a ramp, not a bomb. Response times creep up first - that's your warning sign, which is exactly why production systems watch event loop lag and memory usage as core health metrics, not just "is it up or down."
Nephew: And the fix, in one line?
Uncle: The same layered defense from Part 3 - rate limiting, load balancing across multiple instances, and never doing unbounded work (like buffering an entire huge payload in memory) on a single request. None of it needs to be exotic; it just needs to exist before the flood, not after.
Part 4.7 - The Full Self-Check Roadmap
Uncle: Last thing, and this one's for you to keep, not just read once. Here's every level of Node knowledge, laid out so you can honestly tick off what you actually know versus what you've only heard of.
| Level | Topic | Should know |
|---|---|---|
| 1 | Core Node.js | require vs import, package.json, npm/npx, fs/path/os/http/crypto/events/stream, process.env, timers |
| 2 | Async JavaScript | Callbacks, Promises, async/await, Promise.all/allSettled/race, microtask vs macrotask ordering |
| 3 | Streams โญ | Readable/Writable/Duplex/Transform, pipe(), backpressure - "10GB upload? Use Streams." |
| 4 | Error Handling | try/catch limits, Express error middleware, unhandledRejection, uncaughtException |
| 5 | Memory | Stack vs Heap, GC (mark-and-sweep, generational), memory leaks, why Buffers sit outside the V8 heap |
| 6 | HTTP Internals | Browser โ DNS โ TCP โ TLS โ HTTP โ Express โ Response, what happens before your route even runs |
| 7 | Express Internals | Middleware chain, next(), route matching, body/cookie parsing, sessions |
| 8 | Production Node.js | Logging (Pino/Winston), graceful shutdown, health checks, rate limiting, CORS, Helmet, Nginx, PM2, Docker |
| 9 | Performance | I/O vs CPU-bound, Worker Threads, cluster, caching, Redis, event loop lag |
| 10 | Internals (Advanced) | libuv architecture, native addons, async hooks, V8 GC internals, buffer allocation, stream internals |
Nephew: Honestly, comparing this to what we've covered across all four parts - I think I can tick most of 1 through 7 now.
Uncle: That's real progress for a few Saturdays. 8, 9, and 10 are earned by shipping things, breaking things, and fixing them under pressure - not by reading one more article. That part, no uncle and no chat can hand you. You have to go do it.
Nephew: Fair. Thanks, uncle.
Uncle: Go build something.
Comments
No comments yet. Start the discussion.