Skip to content
Node.js nd http 4 min read

Creating an HTTP Server with the http Module

Node.js ships with a built-in http module that lets you create a fully functional web server without any third-party dependencies. While frameworks like Express add convenience, understanding the raw http module teaches you what every Node web server is actually doing under the hood: accepting connections, reading incoming requests, and writing responses byte by byte. This page walks through creating a server, handling the request and response objects, setting status codes and headers, and even building a small router by hand.

Creating a server

The entry point is http.createServer(). It accepts a request listener — a callback invoked once per incoming HTTP request — and returns a Server instance. The listener receives two arguments: an IncomingMessage (the request, conventionally req) and a ServerResponse (the response, conventionally res).

import http from 'node:http';

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
  res.end('Hello from Node.js!\n');
});

server.listen(3000, () => {
  console.log('Server running at http://localhost:3000/');
});

Run the file and hit it with curl:

node server.js
curl -i http://localhost:3000/

Output:

HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Sun, 14 Jun 2026 10:00:00 GMT
Connection: keep-alive
Content-Length: 20

Hello from Node.js!

If you prefer CommonJS, swap the import for const http = require('node:http');. Everything else stays identical — the node: prefix works in both module systems and guarantees you load the core module rather than a same-named package from node_modules.

Inspecting the request

The req object is a readable stream that also carries useful metadata. The most common properties are req.method, req.url, and req.headers. Note that req.url contains only the path and query string — never the protocol or host — so for full URL parsing you construct a URL against a base.

import http from 'node:http';

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

  console.log(`${req.method} ${pathname}`);
  res.setHeader('Content-Type', 'application/json');
  res.end(JSON.stringify({ method: req.method, pathname, name: searchParams.get('name') }));
});

server.listen(3000);

A request to http://localhost:3000/greet?name=Ada logs and returns:

Output:

GET /greet
{"method":"GET","pathname":"/greet","name":"Ada"}

Status codes and headers

You can set the response status and headers two ways. Setting res.statusCode and calling res.setHeader() individually is convenient when values are computed across several lines. For a single atomic call, res.writeHead(statusCode, headers) sets everything at once. Either way, headers must be sent before any body data — once the first chunk is written, the headers are flushed and become immutable.

const server = http.createServer((req, res) => {
  res.writeHead(201, {
    'Content-Type': 'application/json',
    'X-Powered-By': 'node-http',
    'Cache-Control': 'no-store',
  });
  res.end(JSON.stringify({ created: true }));
});
MemberPurposeNotes
res.statusCodeSet the numeric statusDefaults to 200
res.setHeader(name, value)Set a single headerMust run before the body
res.writeHead(code, headers)Set status + headers togetherOverrides earlier setHeader calls for the same names
res.write(chunk)Stream a body chunkCan be called repeatedly
res.end([chunk])Finish the responseRequired — the request hangs without it

Always call res.end(). If you forget, the client keeps waiting until it times out because the server never signals the response is complete.

Building a router by hand

The single request listener handles every route, so routing is just branching on req.method and the pathname. A clean pattern is a lookup table keyed by "METHOD path", with a fallback for anything unmatched.

import http from 'node:http';

function sendJson(res, status, payload) {
  res.writeHead(status, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(payload));
}

const routes = {
  'GET /': (req, res) => sendJson(res, 200, { message: 'Welcome' }),
  'GET /health': (req, res) => sendJson(res, 200, { status: 'ok' }),
  'GET /users': (req, res) => sendJson(res, 200, [{ id: 1, name: 'Ada' }]),
};

const server = http.createServer((req, res) => {
  const { pathname } = new URL(req.url, `http://${req.headers.host}`);
  const handler = routes[`${req.method} ${pathname}`];

  if (handler) {
    handler(req, res);
  } else {
    sendJson(res, 404, { error: 'Not Found' });
  }
});

server.listen(3000, () => console.log('Listening on :3000'));

Test a known and an unknown route:

curl -s http://localhost:3000/health
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/missing

Output:

{"status":"ok"}
404

This scales surprisingly far. Once you need path parameters (/users/:id), wildcard matching, or middleware, that’s the moment a framework starts paying for itself — but the mechanics are exactly the branching you wrote above.

Listening and shutting down

server.listen() binds to a port and starts accepting connections; it is asynchronous, so the callback fires once the socket is ready. You can also listen on a specific host, a Unix socket path, or let the OS pick a free port by passing 0. For clean shutdowns, call server.close() to stop accepting new connections while letting in-flight requests finish.

const server = http.createServer(handler);
server.listen(3000, '127.0.0.1');

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

Best Practices

  • Use the node:http prefix so you always load the core module, never a shadowing package.
  • Parse req.url with the URL class instead of string splitting — it handles query strings and encoding correctly.
  • Set headers before writing the body, and always finish responses with res.end().
  • Send an explicit Content-Type so clients interpret the body correctly; default to application/json for APIs.
  • Return a real 404 (and 405 for wrong methods) for unmatched routes rather than letting requests hang.
  • Handle SIGTERM/SIGINT with server.close() for graceful shutdown in containers and process managers.
  • Reach for a framework only once hand-rolled routing, body parsing, and error handling become repetitive.
Last updated June 14, 2026
Was this helpful?