Skip to content

Building a Node.js HTTP Server

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 Lifecycle Methods

const server = http.createServer(handler);
server.listen(port, host, callback);
server.close(callback);
server.on('error', (err) => { ... });
  • 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.

process.on('SIGTERM', () => {
  console.log('SIGTERM received, closing server...');
  server.close(() => {
    console.log('Server closed, exiting.');
    process.exit(0);
  });
});

Common Mistakes

  • 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.