HTTP Module - The Backbone of Node.js Web Servers¶
Table of Contents¶
- The Server Core
- The Request Object (IncomingMessage)
- The Response Object (ServerResponse)
- Handling Different HTTP Methods
- Parsing Request Body
- Serving Different Content Types
- Complete Minimal HTTP Server
- Security : HTTP Splitting & Response Smuggling
The Server Core¶
http.createServer() is the factory function that returns a net.Socket-backed server instance listening for TCP connections on the specified port and parsing raw bytes into HTTP semantics
Every Node.js HTTP server starts with this pattern:
const http = require('node:http')
const server = http.createServer((req, res) => {
// every incoming HTTP request lands here
// req is IncomingMessage , res is ServerResponse
res.end('Hello from the trenches')
})
server.listen(3000, () => {
console.log('Server rotting on port 3000')
})
Run that with node server.js and curl localhost:3000 - you get your first response
createServer can also take a separate requestListener - same thing , just decoupled:
function handler(req, res) {
res.end('Decoupled handler')
}
const server = http.createServer(handler)
server.listen(3000)
Under the hood , http.createServer extends net.Server - every HTTP server is actually a TCP server with HTTP parsing layered on top
The Request Object (IncomingMessage)¶
req is a readable stream that inherits from stream.Readable. It contains everything the client sent:
const server = http.createServer((req, res) => {
console.log('Method:', req.method) // GET , POST , PUT , DELETE
console.log('URL:', req.url) // /path?query=string
console.log('Headers:', req.headers) // object , all lowercase keys
console.log('HTTP Version:', req.httpVersion) // 1.1 , 2.0
// query parameters via native URL
const url = new URL(req.url, `http://${req.headers.host}`)
console.log('Parsed pathname:', url.pathname)
console.log('Query param id:', url.searchParams.get('id'))
res.end('Check your server logs')
})
Key req properties: * req.method - HTTP verb string , always uppercase * req.url - raw URL path + query string (NOT parsed by default) * req.headers - all headers as key-value pairs , keys lowercase * req.httpVersion - HTTP version string * req.socket - underlying TCP socket (raw power)
req is a stream - for body data you either pipe it or listen for data events
The Response Object (ServerResponse)¶
res is a writable stream. You build and send the HTTP response through it:
const server = http.createServer((req, res) => {
// set status code
res.statusCode = 200
// set a header directly
res.setHeader('Content-Type', 'text/plain')
// write headers explicitly (after this , you cant modify headers)
res.writeHead(200, {
'Content-Type': 'application/json',
'X-Custom-Header': 'shinobi-value'
})
// write body chunks (can call multiple times for streaming)
res.write('{"message": "partial"}')
// finish the response
res.end('{"message": "complete"}')
})
Common response methods: * res.writeHead(statusCode, headers) - write status + headers atomically * res.setHeader(name, value) - set individual header before writeHead * res.getHeader(name) - read back a header you set * res.removeHeader(name) - remove a header * res.write(chunk) - send a chunk of body data (callable multiple times) * res.end([data]) - signal response is complete , optionally send final chunk
The head vs body wall: Once you write to the body (via res.write() or res.end()), headers are locked. Node.js flips into body mode and any setHeader calls after that throw
Why this matters - if your auth middleware tries to set a header after the controller already wrote body bytes , your response is already corrupted
Handling Different HTTP Methods¶
You manually dispatch on req.method because Node's http module gives you zero routing:
const server = http.createServer((req, res) => {
const { method, url } = req
if (method === 'GET' && url === '/users') {
res.writeHead(200, { 'Content-Type': 'application/json' })
return res.end(JSON.stringify([{ id: 1, name: 'ali' }]))
}
if (method === 'POST' && url === '/users') {
res.writeHead(201, { 'Content-Type': 'application/json' })
return res.end(JSON.stringify({ created: true }))
}
if (method === 'PUT' && url.startsWith('/users/')) {
res.writeHead(200)
return res.end('{"updated": true}')
}
if (method === 'DELETE' && url.startsWith('/users/')) {
res.writeHead(204)
return res.end()
}
// catch-all for unhandled routes
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Route not found' }))
})
405 Method Not Allowed - when the route exists but the method is wrong:
if (url === '/users') {
if (method === 'GET') { /* handle GET */ }
else {
res.writeHead(405, { 'Allow': 'GET' })
return res.end(JSON.stringify({ error: 'Method not allowed' }))
}
}
Parsing Request Body¶
Node.js doesn't parse bodies for you - you read the stream manually because HTTP bodies are streams , not pre-buffered strings
Text/JSON body parsing from scratch:
function parseBody(req) {
return new Promise((resolve, reject) => {
const chunks = []
req.on('data', chunk => chunks.push(chunk))
req.on('end', () => {
const raw = Buffer.concat(chunks).toString()
if (!raw) return resolve({})
try {
resolve(JSON.parse(raw))
} catch (e) {
reject(new Error('Invalid JSON'))
}
})
req.on('error', reject)
})
}
// in your handler:
const server = http.createServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/users') {
const body = await parseBody(req)
console.log('Parsed body:', body)
res.writeHead(201, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ received: body }))
}
})
Why you check Content-Type: A JSON body handler will choke on URL-encoded or multipart data. Always validate:
if (req.headers['content-type'] !== 'application/json') {
res.writeHead(415) // Unsupported Media Type
return res.end('{"error": "send JSON or go home"}')
}
Serving Different Content Types¶
res.setHeader('Content-Type', ...) tells the client how to interpret your bytes:
const server = http.createServer((req, res) => {
const { url } = req
if (url === '/api') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'live', timestamp: Date.now() }))
}
else if (url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end('<h1>Root access granted</h1>')
}
else if (url === '/robots.txt') {
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end('User-agent: *\nDisallow: /admin')
}
else {
res.writeHead(404)
res.end()
}
})
Common Content-Type values: * text/plain - raw text * text/html - HTML documents * application/json - JSON data * application/xml - XML data * application/octet-stream - binary download * multipart/form-data - file uploads * image/png , image/jpeg - image responses
Complete Minimal HTTP Server¶
const http = require('node:http')
const PORT = process.env.PORT || 3000
const server = http.createServer(async (req, res) => {
// CORS headers for API access
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
// Preflight handling
if (req.method === 'OPTIONS') {
res.writeHead(204)
return res.end()
}
// Simple router
const { method, url } = req
if (method === 'GET' && url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' })
return res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }))
}
if (method === 'GET' && url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html' })
return res.end(`
<!DOCTYPE html>
<html><body>
<h1>Node HTTP Server</h1>
<p>This server is held together by duct tape and dreams</p>
</body></html>
`)
}
if ((method === 'POST' || method === 'PUT') && url === '/data') {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const body = Buffer.concat(chunks).toString()
try {
const parsed = JSON.parse(body)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ echo: parsed }))
} catch {
res.writeHead(400)
res.end(JSON.stringify({ error: 'Invalid JSON - try again' }))
}
return
}
// 404 everything else
res.writeHead(404)
res.end('Nothing here but us ghosts')
})
server.listen(PORT, () => {
console.log(`HTTP server faceplanted on port ${PORT}`)
})
Security : HTTP Splitting & Response Smuggling¶
HTTP Response Splitting happens when user input ends up in response headers , letting an attacker inject CRLF (\r\n) sequences to terminate the current response and start a second fake response body
// VULNERABLE - dont do this
const location = req.headers['x-redirect']
res.writeHead(302, { 'Location': location }) // user sends "evil.com\r\n\r\nFakeBody"
An attacker sends x-redirect: /foo\r\nContent-Length: 15\r\n\r\n<h1>injected</h1> and the browser renders the injected HTML under your domain - XSS without script tags
Mitigation: Strip or validate any value going into headers:
function sanitizeHeader(value) {
return String(value).replace(/[\r\n]/g, '')
}
// safe
res.setHeader('Location', sanitizeHeader(userInput))
HTTP Request Smuggling exploits discrepancies between frontend (proxy/CDN) and backend parsing of Content-Length vs Transfer-Encoding headers. Node's http parser is strict about RFC compliance , but if you sit behind nginx or haproxy , parsing differences can let attackers smuggle requests past security controls
Key rules: * Never let user input near header values without sanitization * Set Content-Length explicitly in responses to prevent buffer manipulation * Validate Content-Type before parsing body * Be aware of req.socket - raw TCP access means you can shoot yourself in the foot if you bypass the HTTP parser
prerequisites¶
core_11_url.md - URL parsing , URLSearchParams , constructing URLs
next => web_02_https.md