Build a Small Node.js HTTP Server: Routes, Errors and Shutdown

Build a Small Node.js HTTP Server: Routes, Errors and Shutdown

A small HTTP server is a useful way to learn how Node.js connects a request to a response. The example on this page runs only on your computer: it listens on the loopback address, accepts a health check and a JSON echo request, and never calls an external service. It is a learning exercise, not a production service template.

Diagram of an HTTP request moving through a Node.js server, route handler, response, and graceful shutdown.

Start with the request and response

Node’s http.createServer() gives your handler a request and a response. The request contains a method, URL and a stream of incoming bytes. Your handler decides what status, headers and body to send. Make those decisions explicit: accept only the methods you support, return 404 for an unknown route, and include an Allow header with a 405 response.

The complete runnable implementation tested for this guide appears below. First, notice how its route checks make supported methods and error responses explicit:

if (pathname === '/health') {
  if (request.method !== 'GET') {
    return send(response, 405, { error: 'Use GET.' }, { Allow: 'GET' });
  }
  return send(response, 200, { status: 'ok' });
}

if (pathname !== '/echo') {
  return send(response, 404, { error: 'Route not found.' });
}
if (request.method !== 'POST') {
  return send(response, 405, { error: 'Use POST.' }, { Allow: 'POST' });
}

The helper writes JSON consistently and adds X-Content-Type-Options: nosniff. Keeping response formatting in one helper makes it easier to check that every branch ends the response exactly once.

Read a bounded request body

An incoming HTTP body is a stream, so do not assume it arrives as a single chunk. The example counts bytes as chunks arrive and stops retaining data after 16 KiB. It then parses the joined bytes as JSON. Oversized input receives 413; malformed JSON receives 400. These checks make the example’s behavior predictable for both normal and invalid requests.

let bytes = 0;
const chunks = [];
request.on('data', chunk => {
  bytes += chunk.length;
  if (bytes > 16 * 1024) {
    // Stop accepting this body and return 413.
  } else {
    chunks.push(chunk);
  }
});
request.on('end', () => {
  // Parse Buffer.concat(chunks) only when the body stayed within its limit.
});

The shortened excerpt shows the decision; use the complete file for the oversized-body guard and the single-response handling. A byte limit is not a substitute for validating the shape and meaning of parsed data. A real service should also define authentication, request schemas, logging rules, rate limits and deployment-specific timeouts.

Run and exercise it locally

node server.mjs
curl -i http://127.0.0.1:3000/health
curl -i -X POST http://127.0.0.1:3000/echo \
  -H "Content-Type: application/json" \
  --data '{"message":"hello"}
curl -i http://127.0.0.1:3000/missing

Expected results: /health returns 200 and {"status":"ok"}; a valid POST to /echo returns the parsed value; an unknown path returns 404. A GET to /echo returns 405 with Allow: POST. Send malformed JSON or a body above the limit to see 400 and 413. Stop the process with Ctrl+C to exercise the shutdown handler.

Close cleanly and know the limits

On SIGINT or SIGTERM, the example calls server.close() so Node stops accepting new connections and lets existing work finish. A five-second timer closes remaining connections if they do not drain. Node documents these HTTP server lifecycle methods in its HTTP API reference. In an application, coordinate this with your process manager and choose a timeout that fits the work your handlers perform.

Complete runnable server

Copy this source into a file named server.mjs, then run the commands above:

import http from 'node:http';

const port = Number(process.env.PORT ?? 3000);
const bodyLimit = 16 * 1024;

function send(response, status, value, headers = {}) {
  response.writeHead(status, {
    'Content-Type': 'application/json; charset=utf-8',
    'X-Content-Type-Options': 'nosniff',
    ...headers,
  });
  response.end(JSON.stringify(value));
}

const server = http.createServer((request, response) => {
  const pathname = new URL(request.url, 'http://127.0.0.1').pathname;

  if (pathname === '/health') {
    if (request.method !== 'GET') {
      return send(response, 405, { error: 'Use GET.' }, { Allow: 'GET' });
    }
    return send(response, 200, { status: 'ok' });
  }

  if (pathname !== '/echo') {
    return send(response, 404, { error: 'Route not found.' });
  }
  if (request.method !== 'POST') {
    return send(response, 405, { error: 'Use POST.' }, { Allow: 'POST' });
  }

  let bytes = 0;
  let oversized = false;
  const chunks = [];
  request.on('data', chunk => {
    if (oversized) return;
    bytes += chunk.length;
    if (bytes > bodyLimit) {
      oversized = true;
      return send(response, 413, { error: 'Body exceeds 16 KiB.' });
    }
    chunks.push(chunk);
  });
  request.on('end', () => {
    if (oversized) return;
    try {
      const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
      send(response, 200, { received: value });
    } catch {
      send(response, 400, { error: 'Send valid JSON.' });
    }
  });
});

server.requestTimeout = 10_000;
server.listen(port, '127.0.0.1', () => {
  console.log(`Listening at http://127.0.0.1:${server.address().port}`);
});

function shutdown() {
  server.close(error => {
    if (error) {
      console.error('Shutdown failed.');
      process.exitCode = 1;
    }
  });
  setTimeout(() => server.closeAllConnections(), 5_000).unref();
}
process.once('SIGTERM', shutdown);
process.once('SIGINT', shutdown);

The example’s local checks cover health, method handling, valid and invalid JSON, the body limit and process shutdown. They do not prove behavior behind a proxy, load balancer or production traffic. For broader JavaScript and Node.js study, see the Node.js course recommendation. The course page includes its existing companion download request flow.