NodeJSCreating a Web Server

Creating a Web Server

Now we build a working web server with the http module — handling requests, sending proper responses, and managing the server's lifecycle. The goal here isn't a framework substitute; it's to see the whole request/response cycle with nothing hidden, so that when you later use Express you know exactly what it's doing for you.

The request handler

JS
import { createServer } from 'node:http'

const server = createServer((req, res) => {
  // req: what the client sent · res: what we send back
  console.log(`${req.method} ${req.url}`)       // e.g. 'GET /about'

  res.writeHead(200, { 'Content-Type': 'text/plain' })
  res.end('Hello from Node!\n')
})

server.listen(3000, '127.0.0.1', () => {
  console.log('listening on http://127.0.0.1:3000')
})
`writeHead` sets status + headers in one call
`res.writeHead(statusCode, headers)` sends the status line and headers together. Alternatively set them separately with `res.statusCode = 200` and `res.setHeader(name, value)`. Either way, headers must be sent **before** any body data — once the first byte of body is written, headers are locked.
You must always end the response
Forgetting `res.end()` leaves the client hanging
Every code path in your handler must finish the response — `res.end()` (optionally with final data). If a branch returns without ending, the connection stays open and the browser spins until it times out. This includes error paths: if you `return` early after detecting a bad request, make sure you ended the response first. A missing `end()` is one of the most common raw-`http` bugs.

JS
createServer((req, res) => {
  if (req.url === '/health') {
    res.writeHead(200).end('OK')
    return                              // ✓ ended before returning
  }
  res.writeHead(404).end('Not Found')   // ✓ every path ends the response
}).listen(3000)
Reading method and URL

Routing decisions come from req.method and req.url. Note req.url is the path + query string only (e.g. /search?q=node), not the full URL — parse it with the URL class:

JS
createServer((req, res) => {
  // req.url is a path, so give URL a base to parse against:
  const url = new URL(req.url, `http://${req.headers.host}`)

  console.log(url.pathname)                 // '/search'
  console.log(url.searchParams.get('q'))    // 'node'

  res.end(`you searched: ${url.searchParams.get('q')}`)
}).listen(3000)
Use the WHATWG `URL` class, not the legacy `url.parse`
The global `URL` class (same as in browsers) is the modern, safe way to parse request URLs and query strings — `url.parse()` from the old `url` module is legacy and has known quirks. Because `req.url` is path-relative, pass a base built from the `Host` header. See [Parsing Query Strings](/nodejs/query-strings) for more.
Sending different content types

JS
createServer((req, res) => {
  // JSON
  res.writeHead(200, { 'Content-Type': 'application/json' })
  res.end(JSON.stringify({ message: 'hello' }))
}).listen(3000)

// HTML:    'Content-Type': 'text/html'
// Plain:   'Content-Type': 'text/plain'
// The browser uses Content-Type to decide how to render the body.
Server lifecycle and events

JS
const server = createServer(handler)

server.listen(3000)
server.on('listening', () => console.log('ready'))
server.on('request', (req, res) => {})    // same as the handler arg
server.on('error', (err) => {
  if (err.code === 'EADDRINUSE') {
    console.error('Port 3000 is already in use')
    process.exit(1)
  }
})
server.on('close', () => console.log('server stopped'))

Event

Fires when

request

A request arrives (the handler)

listening

Server bound to its port and ready

connection

A new TCP socket connects (pre-HTTP)

error

Server-level error (e.g. EADDRINUSE)

close

Server stopped accepting connections

Graceful shutdown

A production server should stop cleanly: quit accepting new requests, let in-flight ones finish, then exit. server.close does the first two:

JS
function shutdown() {
  console.log('shutting down...')
  server.close(() => {
    console.log('all connections drained')
    process.exit(0)
  })
  // Safety net: force-exit if something hangs
  setTimeout(() => process.exit(1), 10_000).unref()
}

process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
`server.close()` waits for keep-alive connections too
`close` stops new connections and fires its callback only when **all** existing connections end — but HTTP keep-alive connections can stay open idle, delaying shutdown indefinitely. That's why the `setTimeout(...).unref()` safety net matters: it force-exits if draining stalls. (`.unref()` lets the process exit early if everything *does* drain in time.) In Node 18.2+, `server.closeAllConnections()` can force idle sockets closed.
A small multi-route server

JS
import { createServer } from 'node:http'

createServer((req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`)

  if (req.method === 'GET' && url.pathname === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html' })
    res.end('<h1>Home</h1>')
  } else if (req.method === 'GET' && url.pathname === '/api/time') {
    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify({ now: new Date().toISOString() }))
  } else {
    res.writeHead(404, { 'Content-Type': 'text/plain' })
    res.end('Not Found')
  }
}).listen(3000)
This branching is exactly what a router automates
Hand-written `if/else` on method and path works but grows unwieldy fast. [Basic Routing](/nodejs/routing-basics) shows how to structure it, and frameworks replace it entirely with `app.get('/', handler)`. You're seeing the raw machinery a router abstracts away.
Next
A close look at the two objects every handler receives: [Request & Response Objects](/nodejs/request-response).