Skip to content

HTTP Module - The Backbone of Node.js Web Servers

Table of Contents


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