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
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')
})You must always end the response
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:
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)Sending different content types
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
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 |
|---|---|
| A request arrives (the handler) |
| Server bound to its port and ready |
| A new TCP socket connects (pre-HTTP) |
| Server-level error (e.g. |
| 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:
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)A small multi-route server
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)