Node.js 26 🟢 Building Raw HTTP Servers with node:http and node:https
A web framework is a layer of abstraction over something simpler. Underneath Express, Fastify, Koa, and Hono, there is a module that accepts a TCP connection, parses the HTTP request, and writes the HTTP response. That module is node:http. Understanding it is understanding what the frameworks do, what they hide, and what they cost. The node:https module is the same server with TLS layered underneath. This chapter covers both: how to create a server, how to parse the request, how to construct the response, how to handle streaming bodies, and how to terminate TLS.
Key point: http.createServer() returns an http.Server that emits a request event for each incoming request. The handler receives an http.IncomingMessage (the request) and an http.ServerResponse (the response). The request is a readable stream; the response is a writable stream. The server does not parse the body, route the request, or set headers beyond the defaults. Everything above that is the application’s responsibility—or the framework’s.
Why raw HTTP servers matter
The framework problem. Express adds routing, middleware, and convenience methods. Those abstractions are useful, but they obscure what is happening. When a request is slow, the cause may be in the framework’s middleware chain, in the body parser, or in the underlying socket. Understanding node:http is understanding where the time goes.
The performance problem. A raw http.Server has no middleware chain, no routing table, and no body parser. For a simple endpoint—a health check, a webhook receiver, a static file server—the raw module is faster and uses less memory. The frameworks are worth their cost when their features are needed, and not before.
The streaming problem. HTTP is a streaming protocol. The request body arrives in chunks; the response body can be written in chunks. A framework that buffers the body before handing it to the handler loses the streaming behavior. The raw module exposes the streams directly, which is what a proxy, a file server, or a large upload endpoint needs.
The protocol problem. HTTP/1.1 has specific behaviors: Content-Length versus Transfer-Encoding: chunked, keep-alive, Expect: 100-continue, and trailers. The raw module implements these. A framework may hide them or handle them in a way that is not obvious. When a client behaves unexpectedly, the raw module is where the behavior is visible.
The TLS problem. node:https is node:http with a TLS socket underneath. The server API is identical; the difference is the options object, which provides the certificate and key. Understanding the difference between the two modules is understanding how TLS is layered onto HTTP.
a. Creating a server and handling requests
The http.createServer() function takes a request listener and returns a server. The listener is called once per request.
import { createServer } from 'node:http';
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello, world\n');
});
server.listen(3000, () => {
console.log('Server listening on http://localhost:3000');
});
The req is an http.IncomingMessage. It has the request method, URL, HTTP version, headers, and the body as a readable stream. The res is an http.ServerResponse. It has methods for setting the status, headers, and body.
The res.writeHead() method sets the status code and headers. It must be called before the body is written. The res.end() method finishes the response. If it is called with a string or buffer, that is the body. If it is called with no argument, the response has no body.
The request method and URL are the first things the handler usually inspects.
const server = createServer((req, res) => {
console.log(req.method, req.url);
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not found\n');
});
The req.url is the path and query string, not the full URL. The protocol and host are available in the Host header. The req.headers object contains the request headers, lowercased.
The server handles routing itself. There is no router. A switch on the method and a switch on the path is the simplest form. A more elaborate routing table is the application’s responsibility.
The response can be written in chunks for streaming.
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.write('chunk 1\n');
res.write('chunk 2\n');
res.end('chunk 3\n');
});
Each write sends a chunk. The response is finished by end. For HTTP/1.1, if Content-Length is not set, the response uses chunked transfer encoding.
b. Reading the request body and writing the response
The request body is a readable stream. It is not parsed. For a POST or PUT request, the body must be consumed by the handler.
const server = createServer((req, res) => {
if (req.method !== 'POST') {
res.writeHead(405);
res.end();
return;
}
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
const data = JSON.parse(body);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: data }));
});
});
The data event fires for each chunk. The end event fires when the body is complete. The chunks are concatenated into a buffer and decoded. This buffers the entire body in memory, which is acceptable for small bodies and dangerous for large ones.
For a streaming body, the handler processes each chunk as it arrives.
const server = createServer((req, res) => {
let bytes = 0;
req.on('data', (chunk) => {
bytes += chunk.length;
});
req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ bytesReceived: bytes }));
});
});
This counts the bytes without ever buffering the body. The memory usage is constant regardless of the body size.
For a file upload, the chunks can be piped to a writable stream.
import { createWriteStream } from 'node:fs';
const server = createServer((req, res) => {
if (req.method !== 'PUT') {
res.writeHead(405);
res.end();
return;
}
const destination = createWriteStream('/tmp/upload.bin');
req.pipe(destination);
destination.on('finish', () => {
res.writeHead(201, { 'Content-Type': 'text/plain' });
res.end('Uploaded\n');
});
});
The req.pipe(destination) writes the request body to the file as it arrives. Backpressure is handled by the pipe. The memory usage is bounded by the high-water mark of the streams, not by the body size.
For the response, res.write() and res.end() accept strings, buffers, and Uint8Arrays. A readable stream can be piped to the response.
import { createReadStream } from 'node:fs';
const server = createServer((req, res) => {
const stream = createReadStream('/path/to/file');
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
stream.pipe(res);
});
The file is streamed to the client. The Content-Length is not set, so the response uses chunked encoding. If the file size is known, setting Content-Length allows the client to show a progress bar.
c. HTTPS and server lifecycle
The node:https module creates an HTTPS server. The API is identical to node:http, with one difference: the options object passed to createServer provides the TLS credentials.
import { createServer } from 'node:https';
import { readFileSync } from 'node:fs';
const options = {
key: readFileSync('/path/to/private-key.pem'),
cert: readFileSync('/path/to/certificate.pem'),
};
const server = createServer(options, (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello over HTTPS\n');
});
server.listen(443, () => {
console.log('HTTPS server listening on port 443');
});
The key and cert options are the minimum. The ca option provides the certificate authority chain for client certificate verification. The passphrase option decrypts an encrypted private key. The minVersion and maxVersion options control the TLS protocol versions.
For production, the certificate and key should be loaded from a secure location, not from a path in the repository. The key and cert can be buffers or strings. They are read once at server creation.
The server lifecycle is managed through events and methods.
server.on('listening', () => {
console.log('Server is listening');
});
server.on('error', (err) => {
console.error('Server error:', err);
});
server.on('close', () => {
console.log('Server closed');
});
server.listen(3000);
process.on('SIGTERM', () => {
server.close(() => {
console.log('Server shut down gracefully');
process.exit(0);
});
});
The listening event fires when the server is bound to the port. The error event fires for errors like EADDRINUSE (the port is already in use). The close event fires when the server is closed and all connections have ended.
The server.close() method stops accepting new connections and waits for existing connections to end. It does not terminate active connections. For a graceful shutdown, the server should stop accepting new requests, finish the in-flight requests, and then exit. The server.closeIdleConnections() method closes idle keep-alive connections, and server.closeAllConnections() closes all connections immediately.
The server.setTimeout() method sets the socket timeout. The server.keepAliveTimeout and server.headersTimeout properties control the keep-alive and header timeouts. The defaults are 5000ms for keep-alive and 60000ms for headers in current Node.js versions.
For HTTP/2, the node:http2 module provides a different API. The node:https module is HTTP/1.1 with TLS. The two are not interchangeable.
Complete Example Session
// ============================================
// PART 1: BASIC HTTP SERVER
// ============================================
import { createServer } from 'node:http';
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello, world\n');
});
server.listen(3000);
// ============================================
// PART 2: ROUTING BY METHOD AND PATH
// ============================================
const server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
} else if (req.method === 'GET' && req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<h1>Home</h1>');
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not found\n');
}
});
// ============================================
// PART 3: READING THE REQUEST BODY
// ============================================
const server = createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ bodyLength: body.length }));
});
});
// ============================================
// PART 4: STREAMING THE REQUEST BODY
// ============================================
const server = createServer((req, res) => {
let bytes = 0;
req.on('data', (chunk) => {
bytes += chunk.length;
});
req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ bytesReceived: bytes }));
});
});
// ============================================
// PART 5: PIPING THE REQUEST TO A FILE
// ============================================
import { createWriteStream } from 'node:fs';
const server = createServer((req, res) => {
const destination = createWriteStream('/tmp/upload.bin');
req.pipe(destination);
destination.on('finish', () => {
res.writeHead(201);
res.end('Uploaded\n');
});
});
// ============================================
// PART 6: PIPING A FILE TO THE RESPONSE
// ============================================
import { createReadStream } from 'node:fs';
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
createReadStream('/path/to/file').pipe(res);
});
// ============================================
// PART 7: CHUNKED RESPONSE
// ============================================
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.write('chunk 1\n');
res.write('chunk 2\n');
res.end('chunk 3\n');
});
// ============================================
// PART 8: HTTPS SERVER
// ============================================
import { createServer } from 'node:https';
import { readFileSync } from 'node:fs';
const server = createServer({
key: readFileSync('/path/to/key.pem'),
cert: readFileSync('/path/to/cert.pem'),
}, (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Secure hello\n');
});
server.listen(443);
// ============================================
// PART 9: SERVER LIFECYCLE EVENTS
// ============================================
server.on('listening', () => console.log('Listening'));
server.on('error', (err) => console.error(err));
server.on('close', () => console.log('Closed'));
process.on('SIGTERM', () => {
server.close(() => process.exit(0));
});
// ============================================
// PART 10: GRACEFUL SHUTDOWN
// ============================================
process.on('SIGTERM', () => {
server.close(() => {
console.log('Server closed');
process.exit(0);
});
server.closeIdleConnections();
});
The ten parts covered a basic server, routing, reading the body, streaming the body, piping to a file, piping to the response, chunked responses, HTTPS, lifecycle events, and graceful shutdown.
Quick Reference
Server Methods
| Method | Purpose |
|---|---|
createServer(handler) | Create a server |
createServer(options, handler) | Create an HTTPS server |
server.listen(port) | Bind to a port |
server.close(callback) | Stop accepting connections |
server.closeIdleConnections() | Close idle keep-alive connections |
server.closeAllConnections() | Close all connections |
Request Properties
| Property | Value |
|---|---|
req.method | HTTP method |
req.url | Path and query string |
req.headers | Header object |
req.httpVersion | HTTP version |
req.socket | Underlying socket |
req.on('data', fn) | Body chunks |
req.on('end', fn) | Body complete |
Response Methods
| Method | Purpose |
|---|---|
res.writeHead(status, headers) | Set status and headers |
res.write(chunk) | Write a chunk |
res.end([chunk]) | Finish the response |
res.setHeader(name, value) | Set a single header |
res.getHeader(name) | Read a header |
res.statusCode | Status code |
HTTPS Options
| Option | Purpose |
|---|---|
key | Private key |
cert | Certificate |
ca | CA chain |
passphrase | Key passphrase |
minVersion | Minimum TLS version |
maxVersion | Maximum TLS version |
Events
| Event | Fires When |
|---|---|
listening | Server bound to port |
error | Server error |
close | Server closed |
request | Request received |
connection | Socket connected |
Best Practices
✅ Do This:
// Set the status and headers before writing the body
res.writeHead(200, { 'Content-Type': 'application/json' }); // ✅
// Consume the request body or the connection will hang
req.on('data', () => {});
req.on('end', () => res.end()); // ✅
// Stream large bodies instead of buffering
req.pipe(destination); // ✅
// Use pipe for file responses
createReadStream(file).pipe(res); // ✅
// Handle the error event on the server
server.on('error', (err) => console.error(err)); // ✅
// Shut down gracefully on SIGTERM
process.on('SIGTERM', () => server.close(() => process.exit(0))); // ✅
// Set a timeout on the server
server.setTimeout(30000); // ✅
❌ Don’t Do This:
// Don't forget to end the response
res.write('data'); // no res.end() // ❌
// Don't write the body before the headers
res.write('data');
res.writeHead(200); // error // ❌
// Don't buffer unbounded bodies
const chunks = [];
req.on('data', (c) => chunks.push(c)); // memory risk // ⚠️
// Don't ignore the request body
// The connection will not be reused // ❌
// Don't expose the private key path in the repository
key: readFileSync('./key.pem') // ⚠️
// Don't call server.close() and expect active connections to end
server.close(); // waits for active connections // ⚠️
// Don't use node:https for HTTP/2
// Use node:http2 // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Response never ends | Missing res.end() | Call end() |
| Headers already sent | Wrote body before writeHead | Set headers first |
| Connection hangs | Body not consumed | Read or pipe the body |
| Memory exhaustion | Buffered large body | Stream the body |
EADDRINUSE | Port in use | Use a different port |
| TLS handshake fails | Wrong cert or key | Check files and passphrase |
| Keep-alive not working | Content-Length missing | Set it, or use chunked |
Real-World Examples
1. Health Check
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
}
2. JSON Endpoint
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'hello' }));
3. Redirect
res.writeHead(302, { Location: '/new-path' });
res.end();
4. Static File Server
const filePath = path.join('/public', req.url);
createReadStream(filePath).pipe(res);
5. File Upload
req.pipe(createWriteStream('/tmp/upload.bin'));
6. Proxy Pass
const proxy = http.request(target, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
});
req.pipe(proxy);
7. Server-Sent Events
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
setInterval(() => res.write(`data: ${Date.now()}\n\n`), 1000);
8. CORS Headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST');
9. HTTPS with Client Certificates
const server = createServer({
key: readFileSync('key.pem'),
cert: readFileSync('cert.pem'),
ca: readFileSync('ca.pem'),
requestCert: true,
rejectUnauthorized: true,
}, handler);
10. Graceful Shutdown
process.on('SIGTERM', () => {
server.close(() => process.exit(0));
server.closeIdleConnections();
setTimeout(() => process.exit(1), 10000).unref();
});
Visual
Request Lifecycle
┌─────────────────────────────────────────────────────────────┐
│ CLIENT │
│ │ │
│ ▼ │
│ TCP connection accepted by http.Server │
│ │ │
│ ▼ │
│ HTTP request parsed into IncomingMessage │
│ │ │
│ ├── req.method │
│ ├── req.url │
│ ├── req.headers │
│ └── req (readable stream for body) │
│ │ │
│ ▼ │
│ Request listener invoked │
│ │ │
│ ▼ │
│ Handler writes to ServerResponse │
│ │ │
│ ├── res.writeHead(status, headers) │
│ ├── res.write(chunk) │
│ └── res.end() │
│ │ │
│ ▼ │
│ HTTP response sent │
│ │ │
│ ▼ │
│ Connection kept alive or closed │
│ │
└─────────────────────────────────────────────────────────────┘
Streaming vs Buffering
┌─────────────────────────────────────────────────────────────┐
│ BUFFERING │
│ │
│ req.on('data', chunk => chunks.push(chunk)) │
│ req.on('end', () => { │
│ const body = Buffer.concat(chunks) │
│ // entire body in memory │
│ }) │
│ │
│ Memory: O(body size) │
│ Simple, but dangerous for large bodies │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ STREAMING │
│ │
│ req.pipe(destination) │
│ │
│ Memory: O(highWaterMark) │
│ Constant memory regardless of body size │
│ Backpressure handled by pipe │
│ │
└─────────────────────────────────────────────────────────────┘
HTTP vs HTTPS
┌─────────────────────────────────────────────────────────────┐
│ http.createServer(handler) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ HTTP request/response │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ TCP socket │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ https.createServer(options, handler) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ HTTP request/response │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ TLS (key, cert, ca) │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ TCP socket │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ The API is identical. Only the options differ. │
│ │
└─────────────────────────────────────────────────────────────┘
Graceful Shutdown
┌─────────────────────────────────────────────────────────────┐
│ SIGTERM received │
│ │ │
│ ▼ │
│ server.close(callback) │
│ │ │
│ ├── Stop accepting new connections │
│ ├── Wait for active requests to finish │
│ └── Call callback when done │
│ │ │
│ ▼ │
│ server.closeIdleConnections() │
│ │ │
│ └── Close keep-alive connections with no active request │
│ │ │
│ ▼ │
│ callback runs │
│ │ │
│ ▼ │
│ process.exit(0) │
│ │
│ A timeout should force exit if connections hang. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| HTTP module | node:http |
| HTTPS module | node:https |
| Create server | createServer(handler) |
| Request | http.IncomingMessage |
| Response | http.ServerResponse |
| Request body | Readable stream |
| Response body | Writable stream |
| Set status and headers | res.writeHead() |
| Write body | res.write(), res.end() |
| Stream response | readable.pipe(res) |
| Stream request | req.pipe(writable) |
| HTTPS options | key, cert, ca |
| Graceful shutdown | server.close() |
Key takeaways:
http.createServer()returns a server that emits arequestevent per request. The handler receives anIncomingMessageand aServerResponse. The server does not route, parse the body, or set headers beyond the defaults.- The request body is a readable stream. It must be consumed, or the connection will not be reused. For small bodies, buffer the chunks and concatenate. For large bodies, stream or pipe the data.
- The response is a writable stream.
res.writeHead()sets the status and headers.res.write()andres.end()write the body. The headers must be set before the body is written. - Streaming is the default mode.
req.pipe(destination)andreadable.pipe(res)move data without buffering it in memory. The memory usage is bounded by the stream’s high-water mark, not by the data size. node:httpsisnode:httpwith TLS. The API is identical. The options object provides thekey,cert, andca. TherequestCertandrejectUnauthorizedoptions enable client certificate verification.- The server lifecycle is managed through events.
listening,error, andcloseare the three the server emits. Theserver.close()method stops accepting new connections and waits for active ones to finish. - Graceful shutdown requires
server.close()and a timeout. The close method waits for active connections. A timeout forces the process to exit if connections hang.closeIdleConnections()closes keep-alive connections with no active request.
Remember: node:http and node:https are the foundation that every Node.js web framework is built on. The framework’s routing, middleware, and body parsing are conveniences that the raw module does not provide. When the conveniences are needed, the framework earns its cost. When they are not—a health check, a webhook receiver, a proxy, a file server—the raw module is simpler, faster, and more transparent. The request is a readable stream. The response is a writable stream. The handler decides what to do with them. Everything else is a layer on top, and understanding the layer below makes the layer above easier to reason about.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!