Why Learn Pure Node.js?
Every Node.js developer reaches for Express the moment they need a server. And that's fine — Express is great. But do you actually know what it's doing under the hood?
In this 12-part series, we build four real web apps — a Todo list, a Dashboard, a real-time Chat, and a mini Store — using zero npm packages. No Express, no body-parser, no cors, no nothing. Just pure Node.js built-in modules.
Prerequisites
- Node.js installed (v18+ recommended)
- Basic JavaScript knowledge
- A terminal and a text editor
- No
npm install— ever
The Built-in http Module
Node.js ships with a built-in http module. This is what Express, Fastify, Koa, and every other Node.js framework wraps. When you call app.listen(3000) in Express, it's calling http.createServer() internally.
Let's use it directly.
Your First HTTP Server
Create a file called server.js:
// server.js
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello, World!');
});
server.listen(3000, () => {
console.log('Server running at http://localhost:3000');
});
Run it with node server.js and open http://localhost:3000 — you'll see "Hello, World!".
http.createServer() creates a server. Every incoming request calls your callback with two objects: req (the request) and res (the response). That's the entire HTTP lifecycle in Node.js.
The Request Object
The req object contains everything about the incoming request:
const http = require('http');
const server = http.createServer((req, res) => {
console.log('Method:', req.method); // GET, POST, PUT, DELETE
console.log('URL:', req.url); // /todos, /about, /api/users
console.log('Headers:', req.headers); // all browser headers
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`${req.method} ${req.url}`);
});
server.listen(3000);
The Response Object
The res object is how you send data back:
// Set status + headers together
res.writeHead(200, { 'Content-Type': 'text/html' });
// Or separately
res.statusCode = 404;
res.setHeader('Content-Type', 'application/json');
// Send the body and close the connection
res.end('Response body here');
res.end(). If you forget it, the browser will hang waiting forever. The connection stays open until you end it.
Responding with Different Content Types
HTML
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<h1>Hello</h1><p>This is HTML</p>');
JSON
const data = { todos: [], count: 0 };
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
404 Not Found
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 — Page Not Found');
Building the Todo App Server Foundation
Here's the skeleton we'll build on through this series:
// server.js
const http = require('http');
const PORT = process.env.PORT || 3000;
function handleRequest(req, res) {
const { method, url } = req;
// Simple request logger
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${method} ${url}`);
// Home page
if (method === 'GET' && url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head><title>Pure Node.js Todo</title></head>
<body>
<h1>Pure Node.js Todo App</h1>
<p>No frameworks. No packages. Just Node.js.</p>
<a href="/todos">View Todos</a>
</body>
</html>
`);
return;
}
// Todos API (stub — we'll fill this in later)
if (method === 'GET' && url === '/todos') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ todos: [], message: 'Coming soon!' }));
return;
}
// 404 fallback
res.writeHead(404, { 'Content-Type': 'text/html' });
res.end('<h1>404 — Not Found</h1>');
}
const server = http.createServer(handleRequest);
server.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
How the Event Loop Handles Concurrency
Node.js is single-threaded but handles thousands of concurrent requests. How? The event loop.
When a request comes in, Node registers a callback and immediately moves on to the next request. When the response is ready (after I/O completes), the callback fires. Nothing blocks. This is why Node.js excels at I/O-heavy workloads like web servers.
// Under the hood, every request works like this:
// 1. Request arrives → callback queued in event loop
// 2. Node moves on → handles next request immediately
// 3. I/O completes → callback fires → response sent
// All on a single thread — no thread pools, no blocking
Project Structure for This Series
todo-app/
├── server.js ← HTTP server (this post)
├── router.js ← URL routing (post #2)
├── static/ ← HTML, CSS, JS files (post #3)
│ ├── index.html
│ ├── style.css
│ └── app.js
├── handlers/
│ └── todos.js ← Route handlers (post #4)
└── data/
└── todos.json ← File-based database (post #5)
http.createServer(cb)— the foundation of every Node.js web serverreq.methodandreq.url— how you identify what the client wantsres.writeHead()— sets status code and response headersres.end()— sends body and closes the connection- The event loop — how Node handles concurrency without threads
What's Next
In Post #2, we'll extract routing into a clean router.js module — no more giant if-else chains. We'll handle URL parameters like /todos/42 and build a proper route matching system.
/health route that returns { status: "ok", uptime: process.uptime() } as JSON. Then try sending a 405 Method Not Allowed when someone POSTs to a GET-only route.