|$ curl https://forge-ai.dev/api/markdown?path=docs/nodejs/http-server
$cat docs/http-server.md
updated Recently·20-28 min read·published

HTTP Server

Node.jsHTTPBackendIntermediate🎯Free Tools
Introduction

Node.js is purpose-built for network I/O, and its built-in http module lets you create HTTP servers without installing any dependencies. Every request follows the same lifecycle: a client opens a TCP connection, sends an HTTP request, the server emits a request event, and your handler produces an HTTP response.

In production you rarely use raw http directly. Frameworks like Express and Fastify provide routing, middleware, validation, and error handling. But understanding the underlying module is essential: it teaches you how streams, headers, status codes, and keep-alive actually work, which makes debugging framework code far easier.

This guide starts with the low-level API, then moves through routing and body parsing, compares Express and Fastify, and closes with security, testing, performance, and deployment patterns. All examples target Node.js 20+ and use modern ESM syntax.

The Built-in http Module

The node:http module exposes createServer, which returns a server instance. The callback receives an IncomingMessage (the request) and a ServerResponse (the response). Both are streams, which is why Node.js can handle large payloads with minimal memory.

server.mjs
JavaScript
1import { createServer } from 'node:http';
2
3const server = createServer((req, res) => {
4 res.writeHead(200, { 'Content-Type': 'text/plain' });
5 res.end('Hello from Node.js 20+\n');
6});
7
8server.listen(3000, () => {
9 console.log('Server running at http://localhost:3000/');
10});

The server is event-driven. It binds to a port with listen, emits request for every HTTP request, and emits error for bind failures or unexpected socket problems. Inspect req.method, req.url, and req.headers to decide what to do.

ObjectTypeCommon Use
reqReadable streamRead method, URL, headers, body
resWritable streamSet status, headers, write body, end
servernet.Serverlisten, close, event handling

warning

Always call res.end() or the underlying socket will hang until the timeout fires. Node.js 20 defaults requestTimeout to 5 minutes and headersTimeout to 1 minute, but timeouts are not a substitute for explicit response finalization.

Once you call res.writeHead, the status and headers are flushed. Prefer setting headers first, then writing the body. If you call res.write before explicitly setting a status, Node.js sends 200 OK automatically and implicit headers are locked.

Routing, Headers & Methods

Routing matches a request URL and method to a handler. The built-in module gives you req.url and req.method as strings. For production, parse the URL once with new URL(req.url, 'http://' + req.headers.host) to get pathname and search params reliably.

router.mjs
JavaScript
1import { createServer } from 'node:http';
2
3const routes = {
4 GET: {
5 '/': () => ({ status: 200, body: { ok: true } }),
6 '/health': () => ({ status: 200, body: { status: 'up' } }),
7 },
8 POST: {
9 '/users': async (req) => createUser(req),
10 },
11};
12
13const server = createServer(async (req, res) => {
14 const url = new URL(req.url ?? '/', 'http://' + req.headers.host);
15 const handler = routes[req.method ?? 'GET']?.[url.pathname];
16
17 if (!handler) {
18 res.writeHead(404, { 'Content-Type': 'application/json' });
19 res.end(JSON.stringify({ error: 'Not found' }));
20 return;
21 }
22
23 try {
24 const { status, body } = await handler(req, url);
25 res.writeHead(status, { 'Content-Type': 'application/json' });
26 res.end(JSON.stringify(body));
27 } catch (err) {
28 res.writeHead(500, { 'Content-Type': 'application/json' });
29 res.end(JSON.stringify({ error: 'Internal server error' }));
30 }
31});
32
33server.listen(3000);

HTTP headers are case-insensitive by spec, but Node.js lowercases incoming header names for consistency. Outgoing headers preserve the case you provide. Common request headers to inspect include content-type, content-length, authorization, and accept.

MethodSafeIdempotentTypical Use
GETYesYesRead resources
POSTNoNoCreate or trigger actions
PUTNoYesFull resource replacement
PATCHNoUsuallyPartial updates
DELETENoYesRemove resources

best practice

Return 405 Method Not Allowed when the path exists but the method does not, and include an Allow header listing supported methods. This makes your API predictable and REST-compliant.
Query Parameters & Body Parsing

Query parameters live in the URL and are accessible through url.searchParams. They are always strings, so validate and coerce types explicitly. Never trust query input for authorization or filesystem access without sanitization.

query-params.mjs
JavaScript
1const url = new URL(req.url ?? '/', 'http://' + req.headers.host);
2const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10));
3const limit = Math.min(100, parseInt(url.searchParams.get('limit') ?? '20', 10));
4const tags = url.searchParams.getAll('tag');

Request bodies are streams, not buffers. For JSON APIs, collect chunks, enforce a maximum size, and parse only after the stream ends. This protects against memory exhaustion attacks and malformed payloads.

body-parser.mjs
JavaScript
1function readBody(req, maxBytes = 1_000_000) {
2 return new Promise((resolve, reject) => {
3 const chunks = [];
4 let received = 0;
5
6 req.on('data', (chunk) => {
7 received += chunk.length;
8 if (received > maxBytes) {
9 req.destroy();
10 reject(new Error('Payload too large'));
11 return;
12 }
13 chunks.push(chunk);
14 });
15
16 req.on('end', () => resolve(Buffer.concat(chunks)));
17 req.on('error', reject);
18 });
19}
20
21const data = JSON.parse((await readBody(req)).toString('utf8'));

info

Use req.destroy() to stop receiving data immediately when a limit is exceeded. Simply rejecting the promise leaves the stream flowing, which wastes memory and can crash the process if the client keeps sending.

Backpressure

Backpressure occurs when a producer sends data faster than a consumer can process it. In HTTP this commonly happens when streaming a file to a slow client. Ignoring it causes memory bloat. Use stream.pipe(res) or respect the write return value and the drain event.

backpressure.mjs
JavaScript
1import { createReadStream } from 'node:fs';
2
3const stream = createReadStream('large-file.zip');
4stream.pipe(res); // pipe handles backpressure automatically
Static File Server

Serving static files from disk is a common requirement. The safest approach uses path.resolve and path.join to prevent directory traversal, sets correct MIME types, and streams the file rather than reading it into memory.

static-server.mjs
JavaScript
1import { createServer } from 'node:http';
2import { open } from 'node:fs/promises';
3import path from 'node:path';
4
5const PUBLIC_DIR = path.resolve(process.cwd(), 'public');
6const MIME_TYPES = {
7 '.html': 'text/html', '.css': 'text/css',
8 '.js': 'text/javascript', '.json': 'application/json',
9 '.png': 'image/png',
10};
11
12const server = createServer(async (req, res) => {
13 const url = new URL(req.url ?? '/', 'http://' + req.headers.host);
14 const safePath = path.normalize(url.pathname).replace(/^(\.\.?\/|\/)+/, '');
15 const filePath = path.join(PUBLIC_DIR, safePath);
16
17 if (!filePath.startsWith(PUBLIC_DIR + path.sep)) {
18 res.writeHead(403).end('Forbidden');
19 return;
20 }
21
22 try {
23 const handle = await open(filePath, 'r');
24 const stat = await handle.stat();
25 if (stat.isDirectory()) {
26 await handle.close();
27 res.writeHead(403).end('Forbidden');
28 return;
29 }
30 const ext = path.extname(filePath);
31 res.writeHead(200, {
32 'Content-Type': MIME_TYPES[ext] ?? 'application/octet-stream',
33 'Content-Length': stat.size,
34 });
35 handle.createReadStream().pipe(res).on('close', () => handle.close());
36 } catch (err) {
37 res.writeHead(err.code === 'ENOENT' ? 404 : 500).end();
38 }
39});
40
41server.listen(3000);

warning

Never concatenate user input directly into a filesystem path. The path.join(PUBLIC_DIR, url.pathname) pattern is vulnerable to traversal via ../../etc/passwd. Always normalize, strip leading dots, and verify the resolved path stays inside the public directory.
Express.js

Express wraps the built-in HTTP server with a middleware pipeline: every request passes through matched middleware in registration order before reaching a route handler. Middleware can modify the request, end the response, or pass control with next().

express-app.mjs
JavaScript
1import express from 'express';
2
3const app = express();
4
5app.use(express.json({ limit: '100kb' }));
6app.use(express.urlencoded({ extended: false }));
7
8app.get('/', (req, res) => res.json({ ok: true }));
9app.get('/users/:id', (req, res) => res.json({ id: req.params.id }));
10app.post('/users', (req, res) => res.status(201).json({ created: req.body }));
11
12// 4-argument error handler must be last
13app.use((err, req, res, next) => {
14 console.error(err);
15 res.status(500).json({ error: 'Internal server error' });
16});
17
18app.listen(3000);

Async route handlers in Express require care. If a rejected promise is not caught, the request hangs. Wrap async handlers in a utility or use Express 5, which automatically forwards promise rejections to the error middleware.

express-async.mjs
JavaScript
1const asyncHandler = (fn) => (req, res, next) => {
2 Promise.resolve(fn(req, res, next)).catch(next);
3};
4
5app.get('/users/:id', asyncHandler(async (req, res) => {
6 const user = await db.users.findById(req.params.id);
7 if (!user) return res.status(404).json({ error: 'Not found' });
8 res.json(user);
9}));

best practice

Mount security and parsing middleware before routes, and error handlers after routes. Express matches middleware in declaration order, so an authentication guard placed after an open route will not protect it. Always validate req.body and req.params before use.
Fastify

Fastify is a performance-focused framework built around JSON schemas, encapsulation, and hooks. It automatically serializes responses using the schema you provide, which doubles as documentation and a runtime optimization. Routes, plugins, and decorators are encapsulated by default.

fastify-app.mjs
JavaScript
1import Fastify from 'fastify';
2
3const app = Fastify({ logger: true });
4
5app.get('/users/:id', {
6 schema: {
7 params: {
8 type: 'object',
9 properties: { id: { type: 'string', format: 'uuid' } },
10 required: ['id'],
11 },
12 response: {
13 200: {
14 type: 'object',
15 properties: { id: { type: 'string' }, name: { type: 'string' } },
16 },
17 },
18 },
19}, async (req, reply) => {
20 return { id: req.params.id, name: 'Ada Lovelace' };
21});
22
23app.listen({ port: 3000 });

Hooks run at specific points in the lifecycle. Use onRequest for authentication, preHandler for validation, onSend for response transformation, and onResponse for metrics. If a hook throws, Fastify routes the error to the default error handler.

fastify-hooks.mjs
JavaScript
1app.addHook('onRequest', async (req, reply) => {
2 const token = req.headers.authorization?.replace('Bearer ', '');
3 if (!token) {
4 reply.code(401);
5 throw new Error('Unauthorized');
6 }
7});
8
9app.addHook('onResponse', async (req, reply) => {
10 app.log.info({ method: req.method, url: req.url, statusCode: reply.statusCode });
11});

info

Fastify's built-in JSON serializer is significantly faster than Express's res.json because it compiles response schemas into optimized stringify functions. For high-throughput APIs, defining response schemas is one of the easiest performance wins.

Express offers unmatched ecosystem compatibility and a familiar middleware model, but it requires external validation and manual async error handling in version 4. Fastify provides built-in JSON-schema validation, encapsulated plugins, native async/await support, and higher throughput in most benchmarks because it compiles response serializers.

Security

HTTP servers sit at the edge of your application and are the first target for attackers. Defense in depth means layering controls: secure headers, input validation, rate limiting, least-privilege secrets, and dependency scanning. A single missing header or unvalidated input can expose your entire stack.

security-headers.mjs
JavaScript
1import helmet from 'helmet';
2import express from 'express';
3
4const app = express();
5app.use(helmet({
6 contentSecurityPolicy: {
7 directives: {
8 defaultSrc: ["'self'"],
9 scriptSrc: ["'self'", "'nonce-abc123'"],
10 styleSrc: ["'self'", "'unsafe-inline'"],
11 },
12 },
13 hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
14}));

Input validation should happen as close to the edge as possible. Use Zod, Joi, or JSON schemas to reject malformed data before it reaches business logic. Never use user input directly in file paths, shell commands, SQL queries, or outbound requests.

zod-validation.mjs
JavaScript
1import { z } from 'zod';
2
3const UserQuery = z.object({
4 page: z.coerce.number().int().min(1).default(1),
5 limit: z.coerce.number().int().min(1).max(100).default(20),
6});
7
8const result = UserQuery.safeParse(req.query);
9if (!result.success) {
10 return res.status(400).json({ errors: result.error.flatten() });
11}

warning

Server-Side Request Forgery (SSRF) is a critical risk when your server fetches URLs from user input. Always validate URLs against an allow-list of hosts and schemes, block private IP ranges, and avoid following redirects blindly. Run HTTPS/TLS in production, store secrets in environment variables, configure CORS explicitly, rate-limit public endpoints, and scan dependencies with npm audit.
Testing

Node.js 20 ships with a built-in test runner under node:test and assertion module node:assert/strict. It supports subtests, hooks, mocking, and coverage via --experimental-test-coverage. For HTTP servers, Supertest binds to an ephemeral port and lets you assert on status, headers, and body.

node-test.mjs
JavaScript
1import { test } from 'node:test';
2import assert from 'node:assert/strict';
3import request from 'supertest';
4import { createServer } from 'node:http';
5
6const app = createServer((req, res) => {
7 res.writeHead(200, { 'Content-Type': 'application/json' });
8 res.end(JSON.stringify({ ok: true }));
9});
10
11test('GET / returns 200', async () => {
12 const res = await request(app).get('/');
13 assert.equal(res.status, 200);
14 assert.deepEqual(res.body, { ok: true });
15});

For framework apps, export the configured app without calling listen. Tests can then start and stop the server independently. Use separate test databases, transactional fixtures, or in-memory fakes to keep tests deterministic and fast.

testable-app.mjs
JavaScript
1import express from 'express';
2export const app = express();
3app.get('/health', (req, res) => res.json({ status: 'up' }));
4
5if (import.meta.url === 'file://' + process.argv[1]) {
6 app.listen(3000);
7}

best practice

Keep unit tests fast and deterministic by mocking external services. Reserve true integration tests for the full request path: HTTP parsing, middleware, routing, validation, database access, and serialization together.
Performance

Node.js is single-threaded, so a long-running computation blocks the event loop and stalls all concurrent requests. Use the cluster module to spawn one worker per CPU core, or move heavy work to worker_threads for CPU-bound tasks.

cluster.mjs
JavaScript
1import cluster from 'node:cluster';
2import { availableParallelism } from 'node:os';
3import { createServer } from 'node:http';
4
5if (cluster.isPrimary) {
6 for (let i = 0; i < availableParallelism(); i++) cluster.fork();
7 cluster.on('exit', () => cluster.fork());
8} else {
9 createServer((req, res) => res.end('ok')).listen(3000);
10}

Monitor event loop lag with perf_hooks, profile CPU with node --prof, and analyze with clinic.js. Memory leaks often come from accumulating closures, uncached large objects, or forgetting to remove event listeners. Cache aggressively at the edge, but never cache user-specific data in shared caches.

info

Profile before optimizing. Clustering helps throughput for I/O-bound servers, but if your event loop is blocked by CPU work, moving that work to worker_threads usually gives a bigger latency improvement than adding more cluster workers.
Deployment

Production deployment requires process management, health checks, graceful shutdown, containerization, and environment isolation. PM2 and Docker are the two most common tools in the Node.js ecosystem.

ecosystem.config.js
JavaScript
1// ecosystem.config.js
2module.exports = {
3 apps: [{
4 name: 'api',
5 script: './server.mjs',
6 instances: 'max',
7 exec_mode: 'cluster',
8 env: { NODE_ENV: 'production', PORT: 3000 },
9 kill_timeout: 5000,
10 listen_timeout: 8000,
11 }],
12};

A production Dockerfile should be multi-stage, use a minimal base image, run as a non-root user, and only copy the files needed to run. Distroless images reduce the attack surface further but are harder to debug.

Dockerfile
Dockerfile
1# Dockerfile
2FROM node:20-alpine AS builder
3WORKDIR /app
4COPY package*.json ./
5RUN npm ci --only=production
6
7FROM node:20-alpine AS runner
8WORKDIR /app
9RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
10COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
11COPY --chown=nodejs:nodejs . .
12USER nodejs
13EXPOSE 3000
14CMD ["node", "server.mjs"]

best practice

Implement graceful shutdown by listening for SIGTERM and SIGINT, closing the HTTP server with server.close(), draining in-flight requests, and then exiting. Kubernetes and most PaaS platforms send SIGTERM during scale-down or redeploy.
graceful-shutdown.mjs
JavaScript
1const shutdown = (signal) => {
2 console.log('\nReceived ' + signal + ', shutting down gracefully...');
3 server.close(() => {
4 db.disconnect().then(() => process.exit(0));
5 });
6 setTimeout(() => process.exit(1), 10000).unref();
7};
8
9process.on('SIGTERM', () => shutdown('SIGTERM'));
10process.on('SIGINT', () => shutdown('SIGINT'));