We've all done it.(if you haven't jokes on you)
JavaScript
const express = require('express');
const app = express();
app.use(express.json());
app.get('/', (req, res) => res.send('Hello World'));
app.listen(3000);
Five lines of code, and boom—you have a fully functioning backend server. Express.js makes routing, parsing, and sending HTTP headers look trivial.
Today, I decided to take off the path never threaded on. I built a server using nothing but Node’s native http module. No third-party frameworks, no npm packages, no abstractions.
Here is what I discovered when I moved closer to the metal.
The Bare-Minimum Architecture
When you use raw Node, your entire server runs inside a single callback function passed to http.createServer(). There is no app.get() or app.post(). Every single request—regardless of the URL path or the HTTP method—hits the exact same bottleneck.
JavaScript
const http = require('http');
const app = http.createServer((req, res) => {
// Every single request passes through here
});
app.listen(3000, () => console.log("Server running on port 3000"));
This immediately forced me to confront two things that Express completely abstracts away.
- The Browser is Sneaky
(/favicon.ico)When I set up a basicconsole.log(req.url)and hit localhost:3000 in Chrome, my terminal registered two separate requests.
The first was my expected target: GET /.
The second was a ghost request: GET /favicon.ico.
As it turns out, modern web browsers automatically fire off a hidden, secondary request behind your back to look for the website's tab icon. Express quietly ignores or handles this via middleware, but when you are dealing with raw Node, you are exposed to every single network event.
- The Danger of Leaving a Browser on Read In Express, when you finish a route, you type res.send(). In raw Node, if you simply log the request and don't explicitly terminate the interaction, your browser tab will spin forever.
An HTTP interaction is a strict contract: a request must receive a formal response. To break the silence without Express, you have to write low-level transactions manually:
JavaScript
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.write("Welcome to the home page");
res.end(); // If you forget this, the browser hangs forever
Building a Manual Router
Without app.get() or app.post(), how do you handle routing? You don't use a router object; you use plain old JavaScript conditional logic.
I had to build an if/else control-flow chain evaluating req.url and req.method. It looks like this:
JavaScript
if (req.url === "/" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "text/plain" });
res.write("Welcome to the homepage!");
res.end();
} else if (req.url === "/about" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "text/plain" });
res.write("About Page");
res.end();
} else {
res.writeHead(404, { "Content-Type": "text/plain" });
res.write("404 Not Found");
res.end();
}
Seeing this made me realize that Express routers are essentially beautifully packaged switch statements matching strings under the hood.
The Final Boss: Parsing a POST Body
The biggest eye-opener of this experiment was handling incoming request data (like a JSON object or form data sent via a POST request).
In Express, you just type req.body, and your data is waiting for you. In raw Node, req.body is completely undefined.
Why? Because Node.js handles data using Streams. If a client sends a massive chunk of data (like an image upload), holding the entire file in the server's RAM all at once could cause a massive memory bottleneck or crash the system entirely.
Instead, Node handles incoming data like a conveyor belt, breaking the request payload into tiny binary packets called chunks. To get the full dataset, you have to listen to network events, collect the pieces as they fly in, and stitch them back together manually:
JavaScript
if (req.url === '/echo' && req.method === 'POST') {
let body = [];
// 1. Catch the binary packets as they arrive over the network
req.on('data', (chunk) => {
body.push(chunk);
});
// 2. Wait for Node to signal that the data stream is completely finished
req.on('end', () => {
// 3. Glue the binary buffers together and translate them into a string
const fullBody = Buffer.concat(body).toString();
// 4. Send the response *inside* the event listener
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.write(`Echoed data: ${fullBody}`);
res.end();
});
}
Why the Architecture Matters
Because data streams in over time, you cannot handle a POST request synchronously. If you put res.end() outside of those event listeners at the bottom of the block, Node's asynchronous engine will fire off an empty response to the client before the data chunks have even finished downloading.
When you call app.use(express.json()) in an Express application, the library is writing this exact req.on('data') loop behind the scenes, catching the buffers, converting them to strings, running JSON.parse(), and cleaning up the interface for you.
Conclusion: Why You Should Try This
Is anyone going to build a massive enterprise API using raw Node.js if/else blocks? Absolutely not. Express, Fastify, and NestJS exist for a reason—they keep our code clean, maintainable, and dry.
However, spending an afternoon wrestling with network streams, manual header configurations, and hanging browser threads changes the way you look at frameworks. It bridges the gap between writing code and understanding system architecture.
If you haven't done it yet, open a blank folder, write a naked server.js file, and see what happens. It’s the best way to truly appreciate the tools we use every day.
Top comments (0)