You've already seen http.createServer() in isolation. This lesson builds a small but complete server with several routes, using nothing but Node.js's built-in http module.
A Multi-Route Server With Plain Node.js
A real server needs to respond differently depending on the request's method and path. Without a framework, you do this by inspecting req.method and req.url yourself and branching accordingly, exactly the boilerplate covered in the next lesson on routing.
This lesson focuses on structuring a small server cleanly: separating route logic, sending consistent JSON responses, and handling the inevitable "not found" case.
import http from 'node:http';
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
if (req.method === 'GET' && req.url === '/') {
res.end(JSON.stringify({ message: 'Welcome to the API' }));
} else if (req.method === 'GET' && req.url === '/health') {
res.end(JSON.stringify({ status: 'ok' }));
} else {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Not found' }));
}
});
server.listen(3000, () => {
console.log('Server running at http://localhost:3000');
});
Every branch sets a consistent Content-Type header and calls res.end() exactly once, missing either of those is a common source of bugs in raw http servers.
server.listen() can take a port alone, or a port plus a specific host/interface.
server.close() stops accepting new connections but lets existing ones finish first.
Always attach an 'error' listener, e.g. to catch EADDRINUSE when a port is already taken.
The callback passed to createServer() runs once per incoming request, not once per server.
HTTP Server Cheatsheet
The essential moves for building a server with plain Node.js.
Task
Code
Create server
http.createServer((req, res) => {...})
Start listening
server.listen(3000)
Send JSON
res.end(JSON.stringify(data))
Set status code
res.statusCode = 404
Set a header
res.setHeader('Content-Type', 'application/json')
Handle port conflicts
server.on('error', (err) => {...})
Handling Port Conflicts Gracefully
If another process is already using the requested port, server.listen() fails with an EADDRINUSE error emitted on the 'error' event, not thrown synchronously. Handling it gives a much clearer failure message than an unhandled crash.
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error('Port 3000 is already in use.');
} else {
console.error('Server error:', err);
}
process.exit(1);
});
Shutting Down Gracefully
In production, servers should stop accepting new connections and finish in-flight requests before actually exiting, otherwise deploys and restarts can cut off active clients mid-response.
Forgetting to call res.end() on some code path, leaving certain requests hanging forever.
Not handling EADDRINUSE, leaving a confusing generic crash message instead of a clear diagnosis.
Building complex routing by hand instead of reaching for a framework once the route count grows.
Skipping graceful shutdown, causing dropped connections during deploys and restarts.
Key Takeaways
A raw Node.js server branches on req.method/req.url to decide how to respond.
Every request path must call res.end() exactly once to avoid hanging connections.
Handle the server's 'error' event to catch issues like port conflicts cleanly.
Graceful shutdown (SIGTERM + server.close()) prevents dropped connections during deploys.
Pro Tip
As soon as a raw http server needs more than a handful of routes, switch to Express (covered later in this course) rather than hand-rolling more routing logic, it removes this exact boilerplate and adds middleware support for free.
You've built a working HTTP server. Next, learn dedicated routing patterns for handling many paths cleanly.