Series: Pure Node.js — Zero Dependencies

Post #1: Build an HTTP Server From Scratch

📅 March 2026 ⏱ 8 min read 🏷 Node.js, HTTP, Fundamentals

📚 Pure Node.js Series — Zero Dependencies

→ #1: Build an HTTP Server From Scratch (you are here) #2: Manual Routing — No Express Needed #3: Serving Static Files (HTML, CSS, JS) #4: Handling Forms & POST Requests #5: JSON Files as a Database #6: HTML Templating with String Interpolation #7: Vanilla JS Charts & Dynamic UI #8: WebSockets From Scratch #9: Chat Rooms & Broadcast Messages #10: Sessions & Cookies Without Packages #11: File Uploads & Image Serving #12: Deploy Pure Node.js to a VPS

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.

What you'll gain: Deep understanding of HTTP, routing, sessions, WebSockets, and deployment. This knowledge makes you a better engineer — even if you use frameworks every day. You'll never again wonder "what does Express actually do?"

Prerequisites

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!".

What just happened? 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');
Always call 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)
Key takeaways:
  • http.createServer(cb) — the foundation of every Node.js web server
  • req.method and req.url — how you identify what the client wants
  • res.writeHead() — sets status code and response headers
  • res.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.

Challenge before Post #2: Add a /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.

Building along? Share your code on X/Twitter or GitHub.