Building an HTTP Server from Scratch
No Express Needed
LinkedIn Hook
"Can you build a working HTTP server in Node.js without Express?"
That single interview question has ended more senior backend interviews than any leetcode problem. Most developers reach for
express()reflexively. They have never seenreqandreswithout middleware wrapping them. They cannot tell you whatres.json()actually does under the hood.Here is the truth: Express is just a thin wrapper around Node's built-in
httpmodule. Fastify is too. So is Koa. Every routing framework you have ever used boils down to the same primitives —http.createServer,req.on('data'),res.writeHead,res.end. If you do not know those primitives, you do not really know Node.In Lesson 3.4, I rebuild the core of Express in 60 lines of pure Node — request parsing, JSON body reading, routing, error handling, the whole stack. By the end you will know exactly what your framework abstracts and what it costs.
Read the full lesson -> [link]
#NodeJS #Backend #HTTP #InterviewPrep #WebDevelopment #SoftwareEngineering
What You'll Learn
- How
http.createServerturns a callback into a real TCP listener - The shape of
IncomingMessage(req) andServerResponse(res) objects - How to read
req.method,req.url, andreq.headers - Parsing the URL and query string with the WHATWG
new URL()API - Reading a request body as a stream (
data/endevents andfor await) - Writing responses with
res.writeHead,res.write, andres.end - Building manual routing with
if/elseand aMap-based dispatcher - What Express, Fastify, and Koa abstract — and why interviewers ask about it
The Post Office Analogy — Sorting Letters by Hand
Imagine you run a small post office. Every morning, a truck dumps a pile of envelopes on your counter. Each envelope has three things printed on it: a destination address (URL), an action stamp (GET, POST, PUT, DELETE), and some sender metadata in the corner (headers). Inside the envelope is the actual letter (the request body).
Your job is to sort each envelope. You read the address and the stamp, walk over to the right cubby, do whatever the letter asks, and then write a reply. The reply also has its own status stamp ("DELIVERED", "RETURN TO SENDER", "NOT FOUND") and metadata before the actual reply text.
That is exactly what an HTTP server does. Node's http module is the counter where envelopes land. req is the incoming envelope. res is the empty reply envelope you fill out and send back. Express is a hired clerk who pre-sorts the mail into bins for you — but the post office still works perfectly fine without the clerk. You just have to do the sorting yourself, and that is the skill interviewers want to see.
+---------------------------------------------------------------+
| THE POST OFFICE (HTTP SERVER) |
+---------------------------------------------------------------+
| |
| CLIENT SERVER |
| ------ ------ |
| |
| [Envelope] -- TCP socket --> [http.createServer] |
| | | |
| | v |
| | IncomingMessage (req) |
| | .method -> "POST" |
| | .url -> "/users?id=42" |
| | .headers -> { ... } |
| | .on('data', chunk => ...) |
| | | |
| | v |
| | [Your handler logic] |
| | | |
| | v |
| | ServerResponse (res) |
| | .writeHead(200, { ... }) |
| | .write(body) |
| | .end() |
| | | |
| [Reply] <-- TCP socket -- ---- |
| |
+---------------------------------------------------------------+
Napkin AI Visual Prompt: "Dark gradient (#0a1a0a -> #0d2e16). A post office counter scene. LEFT: a stack of envelopes labeled GET, POST, PUT, DELETE flying toward a green (#68a063) sorting machine in the center labeled 'http.createServer'. RIGHT: sorted reply envelopes flying back labeled '200', '404', '500'. Amber (#ffb020) arrows showing flow. White monospace labels."
Example 1 — Hello World Server
The smallest useful Node server is shockingly small. No npm install, no dependencies, just one core module.
// server.js
// Import the built-in http module - no installation needed
const http = require('node:http');
// createServer takes a callback that runs on every incoming request
// req = IncomingMessage (a Readable stream)
// res = ServerResponse (a Writable stream)
const server = http.createServer((req, res) => {
// writeHead sets status code and response headers in one call
res.writeHead(200, { 'Content-Type': 'text/plain' });
// end sends the final body chunk and closes the response
res.end('Hello from raw Node!\n');
});
// Tell the OS to bind a TCP socket on port 3000
server.listen(3000, () => {
console.log('Server running at http://localhost:3000');
});
What just happened under the hood?
http.createServerreturns anhttp.Serverinstance, which extendsnet.Server(a raw TCP server).server.listen(3000)opens a TCP socket on port 3000 and starts the kernelaccept()loop.- When a browser sends bytes, Node parses them into HTTP request semantics and constructs an
IncomingMessageobject. - Node also constructs a
ServerResponseobject wired to the same socket. - Your callback fires with
(req, res). Whatever you write toresgets serialized as a valid HTTP/1.1 response and flushed to the client. res.end()signals "I am done writing" — Node sends aContent-Lengthheader (or terminates the chunked transfer) and the connection is either kept alive or closed.
That is the entire framework. Everything else — Express, Fastify, NestJS — is convenience layered on top of this six-step dance.
Example 2 — Inspecting the Request
Real servers need to know who is asking for what. The req object exposes everything you need.
// inspect.js
const http = require('node:http');
const server = http.createServer((req, res) => {
// req.method -> the HTTP verb as an uppercase string
// Common values: 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'
console.log('Method:', req.method);
// req.url -> the path AND query string, but NOT the host or protocol
// For "http://localhost:3000/users?id=42" it is "/users?id=42"
console.log('URL:', req.url);
// req.headers -> a plain object with lowercased header names
// Always lowercased so you can look them up consistently
console.log('User-Agent:', req.headers['user-agent']);
console.log('Host:', req.headers['host']);
// req.httpVersion -> "1.1" or "2.0" depending on the client
console.log('HTTP Version:', req.httpVersion);
// req.socket gives you the underlying TCP socket
// Useful for getting the client IP address
console.log('Client IP:', req.socket.remoteAddress);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
method: req.method,
url: req.url,
headers: req.headers,
}));
});
server.listen(3000);
Key thing to remember: req.url is just the path and query string. It does not contain the protocol or host. To get the full URL or to safely parse the query string, you use the WHATWG URL constructor.
Example 3 — Parsing URLs with new URL()
The legacy url.parse() function is deprecated. The modern way to dissect a URL is new URL(), which is the same API you use in browsers.
// url-parsing.js
const http = require('node:http');
const server = http.createServer((req, res) => {
// new URL() needs an absolute URL, but req.url is relative
// So we synthesize an absolute URL using the Host header
const fullUrl = new URL(req.url, `http://${req.headers.host}`);
// Now we can read every piece of the URL cleanly
console.log('pathname:', fullUrl.pathname); // "/search"
console.log('search:', fullUrl.search); // "?q=node&limit=10"
// searchParams is an iterable URLSearchParams object
// Use .get() for a single value, .getAll() for repeated keys
const query = fullUrl.searchParams.get('q');
const limit = fullUrl.searchParams.get('limit');
// You can also iterate every query parameter
const allParams = {};
for (const [key, value] of fullUrl.searchParams) {
allParams[key] = value;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
pathname: fullUrl.pathname,
query,
limit,
allParams,
}));
});
server.listen(3000);
// Try: curl "http://localhost:3000/search?q=node&limit=10"
Why synthesize an absolute URL? The WHATWG URL constructor refuses to parse relative URLs without a base. Passing http://${req.headers.host} as the base gives it the protocol and host it needs. If your server runs behind HTTPS, use https://, or read req.headers['x-forwarded-proto'] when behind a proxy.
Example 4 — Reading a Request Body as a Stream
Here is where most beginners get stuck. The body of a POST or PUT request does not arrive all at once. It is streamed in chunks. You have to collect those chunks and concatenate them yourself.
The Classic Event-Based Approach
// body-events.js
const http = require('node:http');
const server = http.createServer((req, res) => {
// Only read a body for methods that have one
if (req.method !== 'POST' && req.method !== 'PUT') {
res.writeHead(405, { 'Content-Type': 'text/plain' });
return res.end('Method Not Allowed');
}
// Buffer chunks as they arrive on the socket
const chunks = [];
// 'data' fires every time the kernel hands Node a new chunk
// chunk is a Buffer by default (binary-safe)
req.on('data', (chunk) => {
chunks.push(chunk);
});
// 'end' fires after the last chunk - now we have the full body
req.on('end', () => {
// Concatenate all the binary chunks into one Buffer, then to string
const raw = Buffer.concat(chunks).toString('utf8');
try {
// Try to parse as JSON
const data = JSON.parse(raw);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: data }));
} catch (err) {
// Malformed JSON -> return 400 Bad Request
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('Invalid JSON body');
}
});
// 'error' fires if the socket dies mid-upload
req.on('error', (err) => {
console.error('Request stream error:', err);
res.writeHead(500);
res.end();
});
});
server.listen(3000);
The Modern Async Iterator Approach
Since Node 10, req is an async iterable. You can for await over it and skip the event handlers entirely.
// body-async.js
const http = require('node:http');
// Helper: read the entire request body as a UTF-8 string
async function readBody(req) {
const chunks = [];
// for-await pulls each chunk as it arrives - no manual event wiring
for await (const chunk of req) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString('utf8');
}
const server = http.createServer(async (req, res) => {
if (req.method === 'POST') {
try {
const raw = await readBody(req);
const data = JSON.parse(raw);
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ created: true, data }));
} catch (err) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('Bad Request: ' + err.message);
}
} else {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Send a POST with JSON');
}
});
server.listen(3000);
This is essentially what express.json() middleware does — it reads the stream, concatenates chunks, parses JSON, and attaches the result to req.body. About 30 lines of code that 99% of Node developers never read.
Example 5 — Manual Routing with a Map Dispatcher
Routing is just "match the method and path, then call the right function." You can do it with if/else, but a Map keyed on METHOD path is cleaner and faster.
// router.js
const http = require('node:http');
// Helper: read body as JSON (returns null if empty or invalid)
async function readJson(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
if (chunks.length === 0) return null;
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
return null;
}
}
// Helper: send a JSON response in one call
function sendJson(res, status, payload) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
}
// In-memory user store - replace with a real database in production
const users = new Map();
let nextId = 1;
// Routing table: "METHOD path" -> handler function
const routes = new Map();
// GET /users -> list all users
routes.set('GET /users', (req, res) => {
sendJson(res, 200, Array.from(users.values()));
});
// POST /users -> create a new user
routes.set('POST /users', async (req, res) => {
const body = await readJson(req);
if (!body || !body.name) {
return sendJson(res, 400, { error: 'name is required' });
}
const user = { id: nextId++, name: body.name };
users.set(user.id, user);
sendJson(res, 201, user);
});
// GET /health -> liveness probe
routes.set('GET /health', (req, res) => {
sendJson(res, 200, { status: 'ok', uptime: process.uptime() });
});
const server = http.createServer(async (req, res) => {
try {
// Parse the URL so the routing key uses pathname only (no query string)
const url = new URL(req.url, `http://${req.headers.host}`);
const key = `${req.method} ${url.pathname}`;
// Look up the handler in the routing table
const handler = routes.get(key);
if (handler) {
// Found a matching route - run it
await handler(req, res);
} else {
// No match -> 404
sendJson(res, 404, { error: 'Not Found', path: url.pathname });
}
} catch (err) {
// Any thrown error -> 500
console.error('Handler error:', err);
if (!res.headersSent) {
sendJson(res, 500, { error: 'Internal Server Error' });
}
}
});
server.listen(3000, () => {
console.log('Mini-Express running on http://localhost:3000');
});
That is a working REST API in about 60 lines, with no dependencies. Compare it mentally to the Express version — app.get('/users', ...) and app.post('/users', ...) — and you will see the abstraction is paper thin. Express adds dynamic params (/users/:id), middleware chaining, and a next() callback. Everything else is the same.
The Request/Response Lifecycle
Understanding the order of events is what separates a developer who uses a framework from one who can debug it under load.
+---------------------------------------------------------------+
| HTTP REQUEST/RESPONSE LIFECYCLE IN NODE |
+---------------------------------------------------------------+
| |
| [1] Client opens TCP connection to port 3000 |
| | |
| v |
| [2] Kernel accepts the socket, hands it to Node |
| | |
| v |
| [3] Node's HTTP parser reads bytes off the socket |
| | (parses the request line and headers) |
| v |
| [4] IncomingMessage (req) is constructed |
| ServerResponse (res) is constructed |
| | |
| v |
| [5] 'request' event fires -> your callback runs |
| | |
| v |
| [6] You read req.method / req.url / req.headers |
| | |
| v |
| [7] If body present: req emits 'data' chunks |
| | (you collect them or for-await over req) |
| v |
| [8] req emits 'end' -> body is fully received |
| | |
| v |
| [9] You call res.writeHead(status, headers) |
| | (this sends the status line + headers immediately) |
| v |
| [10] You call res.write(chunk) zero or more times |
| | (each chunk is flushed to the socket) |
| v |
| [11] You call res.end([finalChunk]) |
| | (Node terminates the response) |
| v |
| [12] Connection is kept alive (HTTP/1.1) or closed |
| |
+---------------------------------------------------------------+
Note step 9 carefully: once you call writeHead or write, you cannot change the status code or headers anymore. They have already been sent down the wire. This is why error handlers always check if (!res.headersSent) before trying to write a 500.
Common Mistakes
1. Forgetting to call res.end().
Without res.end(), the response is never finalized. The client hangs until it times out, and your server slowly leaks open sockets. Every code path — including error paths — must end with res.end(). Frameworks like Express call it for you when you return a response, which hides this requirement.
2. Trying to read req.body directly.
There is no req.body on a raw IncomingMessage. The body is a stream. You must consume the stream with data/end events or for await. req.body only exists because middleware like express.json() puts it there after reading the stream itself.
3. Using url.parse() instead of new URL().
The legacy url.parse() from Node's url module is deprecated and has security issues with malformed input. Always use new URL(req.url, base) — it is the WHATWG standard, faster, and safer.
4. Setting headers after writing the body.
res.setHeader() only works before the headers are flushed. Once res.write() or res.writeHead() runs, attempting to set a header throws ERR_HTTP_HEADERS_SENT. Decide your status code and headers before you start writing.
5. Not handling stream errors.
If a client uploads a 100 MB file and disconnects after 30 MB, the req stream emits an 'error' event. If you do not listen for it, Node crashes the entire process with an uncaught exception. Always attach a req.on('error', ...) handler — or better, wrap the whole thing in try/catch with for await.
6. Assuming req.url is the full URL.
req.url is just the path and query string. There is no protocol, no host. If you log req.url and expect to see https://api.example.com/users, you will be disappointed.
Interview Questions
1. "How would you build an HTTP server in Node without Express?"
You import the built-in http module, call http.createServer((req, res) => { ... }), and call .listen(port) on the returned server. Inside the callback, req is an IncomingMessage (a Readable stream) carrying the method, URL, and headers, and res is a ServerResponse (a Writable stream) that you fill with writeHead, write, and end. For routing you switch on req.method and the parsed req.url pathname. For request bodies you read the req stream with data/end events or a for await loop, then parse the resulting buffer as JSON or form data. That is the entire framework — Express is just convenience on top of these primitives.
2. "What is the difference between IncomingMessage and ServerResponse?"
IncomingMessage represents the request the client sent. It is a Readable stream — you consume bytes from it — and it carries metadata like method, url, headers, httpVersion, and socket. ServerResponse represents the reply you are building. It is a Writable stream — you push bytes into it — and it has methods like writeHead, setHeader, write, and end. They are both wired to the same underlying TCP socket, but they flow in opposite directions: req is bytes coming in, res is bytes going out. Importantly, the same classes get reused on the client side too: when you make an outbound HTTP request with http.request(), the request you send is a ClientRequest (Writable) and the response you receive is an IncomingMessage (Readable). Same IncomingMessage class, opposite role.
3. "How do you read a JSON request body in raw Node?"
The body arrives as a stream of Buffer chunks, not as a single string. The classic approach is to listen for data events to collect chunks into an array, then in the end event call Buffer.concat(chunks).toString('utf8') and JSON.parse the result. The modern approach is to use for await (const chunk of req) because req is an async iterable since Node 10 — you collect the chunks the same way but without manually wiring event handlers. Either way you must handle parse errors with a try/catch and respond with a 400 Bad Request if the JSON is malformed. This is exactly what express.json() middleware does internally.
4. "Why does it matter that you can build a server without Express? Aren't frameworks always better?"
Three reasons. First, debugging: when an Express middleware misbehaves, you need to know what req and res actually are at the raw level to figure out what the middleware is mutating. Second, performance: frameworks add overhead for features you may not need, and high-throughput services like proxies or webhook receivers are often written in raw http to shave off allocations. Third, alternative runtimes: serverless platforms, edge workers, and tools like Fastify all work directly with the raw http API or a thin wrapper around it. If you only know Express, you cannot move between them. Knowing the raw layer is what makes you portable and what makes you a senior Node engineer.
5. "What does res.writeHead do that res.setHeader does not?"
res.setHeader(name, value) stores a header in the pending response object, but does not send anything yet. You can call it multiple times and even overwrite headers. res.writeHead(statusCode, headers) is a one-shot call that sets the status code, optionally merges in any headers, and immediately flushes the status line and the entire header block to the socket. After writeHead runs, the headers are gone — you cannot add or change them anymore. The convention is to use setHeader early when you are still deciding things, and finish with either an explicit writeHead or just res.end() (which implicitly calls writeHead(200) if you have not called it yet).
Quick Reference — Raw HTTP Cheat Sheet
+---------------------------------------------------------------+
| NODE HTTP MODULE CHEAT SHEET |
+---------------------------------------------------------------+
| |
| CREATE A SERVER: |
| const http = require('node:http') |
| const server = http.createServer((req, res) => { ... }) |
| server.listen(3000) |
| |
| REQUEST OBJECT (req): |
| req.method -> 'GET' | 'POST' | ... |
| req.url -> '/path?query=string' |
| req.headers -> { 'host': '...', ... } (lowercased) |
| req.httpVersion -> '1.1' |
| req.socket.remoteAddress -> client IP |
| |
| PARSE URL: |
| const u = new URL(req.url, `http://${req.headers.host}`) |
| u.pathname -> '/path' |
| u.searchParams.get('q') -> query value |
| |
| READ BODY (modern): |
| const chunks = [] |
| for await (const c of req) chunks.push(c) |
| const body = Buffer.concat(chunks).toString('utf8') |
| |
| RESPONSE OBJECT (res): |
| res.setHeader(name, value) -> stage a header |
| res.writeHead(status, headers) -> flush status + headers |
| res.write(chunk) -> write a body chunk |
| res.end([chunk]) -> finalize response |
| res.headersSent -> true if already flushed |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| KEY RULES |
+---------------------------------------------------------------+
| |
| 1. Always call res.end() on every code path |
| 2. req has no .body - you must read the stream yourself |
| 3. Use new URL(), never the legacy url.parse() |
| 4. Set headers BEFORE calling writeHead/write |
| 5. Listen for 'error' on req to avoid uncaught crashes |
| 6. Check res.headersSent before writing in error handlers |
| 7. req.url is path+query only, no host or protocol |
| |
+---------------------------------------------------------------+
| Feature | Raw http | Express |
|---|---|---|
| Routing | Manual if/else or Map | app.get/post/... |
| Body parsing | Read stream yourself | express.json() middleware |
| URL params | Parse manually | :id route params |
| Middleware chain | Call functions yourself | next() callback |
| Error handling | try/catch + headersSent | error middleware |
| Dependencies | Zero (built-in) | One npm install |
| Lines for a REST API | ~60 | ~20 |
| Allocations per request | Minimal | More (middleware stack) |
Prev: Lesson 3.3 -- Events and EventEmitter Next: Lesson 3.5 -- Crypto and Security Basics
This is Lesson 3.4 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.