HTTP Server
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 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.
| 1 | import { createServer } from 'node:http'; |
| 2 | |
| 3 | const server = createServer((req, res) => { |
| 4 | res.writeHead(200, { 'Content-Type': 'text/plain' }); |
| 5 | res.end('Hello from Node.js 20+\n'); |
| 6 | }); |
| 7 | |
| 8 | server.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.
| Object | Type | Common Use |
|---|---|---|
| req | Readable stream | Read method, URL, headers, body |
| res | Writable stream | Set status, headers, write body, end |
| server | net.Server | listen, close, event handling |
warning
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 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.
| 1 | import { createServer } from 'node:http'; |
| 2 | |
| 3 | const 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 | |
| 13 | const 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 | |
| 33 | server.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.
| Method | Safe | Idempotent | Typical Use |
|---|---|---|---|
| GET | Yes | Yes | Read resources |
| POST | No | No | Create or trigger actions |
| PUT | No | Yes | Full resource replacement |
| PATCH | No | Usually | Partial updates |
| DELETE | No | Yes | Remove resources |
best practice
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.
| 1 | const url = new URL(req.url ?? '/', 'http://' + req.headers.host); |
| 2 | const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10)); |
| 3 | const limit = Math.min(100, parseInt(url.searchParams.get('limit') ?? '20', 10)); |
| 4 | const 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.
| 1 | function 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 | |
| 21 | const data = JSON.parse((await readBody(req)).toString('utf8')); |
info
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.
| 1 | import { createReadStream } from 'node:fs'; |
| 2 | |
| 3 | const stream = createReadStream('large-file.zip'); |
| 4 | stream.pipe(res); // pipe handles backpressure automatically |
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.
| 1 | import { createServer } from 'node:http'; |
| 2 | import { open } from 'node:fs/promises'; |
| 3 | import path from 'node:path'; |
| 4 | |
| 5 | const PUBLIC_DIR = path.resolve(process.cwd(), 'public'); |
| 6 | const MIME_TYPES = { |
| 7 | '.html': 'text/html', '.css': 'text/css', |
| 8 | '.js': 'text/javascript', '.json': 'application/json', |
| 9 | '.png': 'image/png', |
| 10 | }; |
| 11 | |
| 12 | const 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 | |
| 41 | server.listen(3000); |
warning
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().
| 1 | import express from 'express'; |
| 2 | |
| 3 | const app = express(); |
| 4 | |
| 5 | app.use(express.json({ limit: '100kb' })); |
| 6 | app.use(express.urlencoded({ extended: false })); |
| 7 | |
| 8 | app.get('/', (req, res) => res.json({ ok: true })); |
| 9 | app.get('/users/:id', (req, res) => res.json({ id: req.params.id })); |
| 10 | app.post('/users', (req, res) => res.status(201).json({ created: req.body })); |
| 11 | |
| 12 | // 4-argument error handler must be last |
| 13 | app.use((err, req, res, next) => { |
| 14 | console.error(err); |
| 15 | res.status(500).json({ error: 'Internal server error' }); |
| 16 | }); |
| 17 | |
| 18 | app.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.
| 1 | const asyncHandler = (fn) => (req, res, next) => { |
| 2 | Promise.resolve(fn(req, res, next)).catch(next); |
| 3 | }; |
| 4 | |
| 5 | app.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
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.
| 1 | import Fastify from 'fastify'; |
| 2 | |
| 3 | const app = Fastify({ logger: true }); |
| 4 | |
| 5 | app.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 | |
| 23 | app.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.
| 1 | app.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 | |
| 9 | app.addHook('onResponse', async (req, reply) => { |
| 10 | app.log.info({ method: req.method, url: req.url, statusCode: reply.statusCode }); |
| 11 | }); |
info
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.
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.
| 1 | import helmet from 'helmet'; |
| 2 | import express from 'express'; |
| 3 | |
| 4 | const app = express(); |
| 5 | app.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.
| 1 | import { z } from 'zod'; |
| 2 | |
| 3 | const 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 | |
| 8 | const result = UserQuery.safeParse(req.query); |
| 9 | if (!result.success) { |
| 10 | return res.status(400).json({ errors: result.error.flatten() }); |
| 11 | } |
warning
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.
| 1 | import { test } from 'node:test'; |
| 2 | import assert from 'node:assert/strict'; |
| 3 | import request from 'supertest'; |
| 4 | import { createServer } from 'node:http'; |
| 5 | |
| 6 | const app = createServer((req, res) => { |
| 7 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 8 | res.end(JSON.stringify({ ok: true })); |
| 9 | }); |
| 10 | |
| 11 | test('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.
| 1 | import express from 'express'; |
| 2 | export const app = express(); |
| 3 | app.get('/health', (req, res) => res.json({ status: 'up' })); |
| 4 | |
| 5 | if (import.meta.url === 'file://' + process.argv[1]) { |
| 6 | app.listen(3000); |
| 7 | } |
best practice
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.
| 1 | import cluster from 'node:cluster'; |
| 2 | import { availableParallelism } from 'node:os'; |
| 3 | import { createServer } from 'node:http'; |
| 4 | |
| 5 | if (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
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.
| 1 | // ecosystem.config.js |
| 2 | module.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.
| 1 | # Dockerfile |
| 2 | FROM node:20-alpine AS builder |
| 3 | WORKDIR /app |
| 4 | COPY package*.json ./ |
| 5 | RUN npm ci --only=production |
| 6 | |
| 7 | FROM node:20-alpine AS runner |
| 8 | WORKDIR /app |
| 9 | RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 |
| 10 | COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules |
| 11 | COPY --chown=nodejs:nodejs . . |
| 12 | USER nodejs |
| 13 | EXPOSE 3000 |
| 14 | CMD ["node", "server.mjs"] |
best practice
| 1 | const 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 | |
| 9 | process.on('SIGTERM', () => shutdown('SIGTERM')); |
| 10 | process.on('SIGINT', () => shutdown('SIGINT')); |