DEV Community

Cover image for Node.js Internals Explained by Uncle to Nephew — Part 4: Express Plumbing, Error Handling & The Full Roadmap
surajrkhonde
surajrkhonde

Posted on

Node.js Internals Explained by Uncle to Nephew — Part 4: Express Plumbing, Error Handling & The Full Roadmap

Bonus round. Parts 1–3 covered why Node exists, what's happening inside it, and the full request journey. This part mops up the pieces that didn't fit anywhere else — the Express plumbing, error handling, and a checklist to test yourself against.


Saturday, Round 4

Nephew: Uncle, one more round? I promise this is the last one for a while.

Uncle: pours chai — you said that last time too. Fine, what's bugging you now?

Nephew: Small things, actually. express.json(), cookie-parser, express.Router() — I use all of them, copy-pasted from old projects, but I couldn't explain any of them if you asked me directly.

Uncle: That's exactly the right instinct — the things you copy-paste without understanding are always the things that break at 2 AM. Let's fix that.


Part 4.1 — Two Directions Node Never Confuses

Uncle: 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()
Enter fullscreen mode Exit fullscreen mode

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:            File Reading:
Browser                           JavaScript
   |                                  |
   OS                               libuv
   |                                  |
 libuv                          Worker Thread
   |                                  |
Event Loop                       Operating System
   |                                  |
JavaScript                          Disk
                                      |
                                Worker Thread
                                      |
                                    libuv
                                      |
                                Event Loop
                                      |
                                JavaScript
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

Uncle: Without any body parser:

app.post('/login', (req, res) => {
  console.log(req.body); // undefined
});
Enter fullscreen mode Exit fullscreen mode

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: '...' }
});
Enter fullscreen mode Exit fullscreen mode

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: "..." }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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' }
});
Enter fullscreen mode Exit fullscreen mode
"sessionId=abc123; theme=dark"
            |
      cookie-parser splits + decodes
            |
req.cookies = { sessionId: "abc123", theme: "dark" }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
// app.js
const userRoutes = require('./routes/userRoutes');
app.use('/users', userRoutes);
Enter fullscreen mode Exit fullscreen mode
Request: GET /users/42
      |
app.use('/users', userRoutes)  ← matches the prefix
      |
inside userRoutes: router.get('/:id', ...)  ← matches the rest
      |
getUserById(req, res) runs
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/catch works directly
Async error (Promise)     →  try/catch works ONLY if you `await` it
Error inside a callback   →  try/catch around the callback CANNOT catch it
Uncaught anywhere         →  process may crash entirely
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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' });
});
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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    → memory pressure increases,
               requests keeps growing                   GC runs more often, pauses grow

Breaking point: event loop is constantly busy        → the process becomes
                (long queue + GC pauses)                 unresponsive or crashes
Enter fullscreen mode Exit fullscreen mode

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 1  — Core Node.js
Level 2  — Async JavaScript
Level 3  — Streams
Level 4  — Error Handling
Level 5  — Memory
Level 6  — HTTP Internals
Level 7  — Express Internals
Level 8  — Production Node.js
Level 9  — Performance
Level 10 — Node.js Internals (Advanced)
Enter fullscreen mode Exit fullscreen mode
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. Come back when it breaks — that's always where the next real lesson is.


What we covered in Part 4

  • The two directions every Node operation falls into: incoming events vs outgoing async calls
  • express.json() / express.urlencoded() — what they parse and why req.body is empty without them
  • cookie-parser — turning the raw Cookie header into a usable object
  • express.Router() — why it exists, how mounting strips the prefix, Route vs Router vs Controller
  • Error handling — why try/catch fails around plain callbacks, Express's 4-argument error middleware, unhandledRejection and uncaughtException
  • An honest, non-dramatic picture of how a system degrades under a request flood
  • A full 10-level self-check roadmap to know exactly what's next

This closes the 4-part Node.js Internals series — the why, the internals, the full request journey, and the plumbing + roadmap to keep going on your own.

Top comments (0)