Creating a Web Server

A web server is a program that listens for incoming HTTP requests and sends back responses: pages, JSON data, files, or anything else a client asks for. Node.js was designed for exactly this job. Its non-blocking, event-driven core lets a single process handle thousands of simultaneous connections without spawning a thread per request. The built-in node:http module gives you the raw tools to build a server yourself, with no framework in the way. Understanding it is the foundation for everything that follows — Express, Fastify, and every other Node web framework are built directly on top of this module.

Overview: How an HTTP Server Works in Node.js

The node:http module sits on top of Node’s TCP networking layer and adds an HTTP parser. When you call http.createServer(), Node opens a listening socket. For every incoming connection, libuv (Node’s C++ I/O engine) hands the raw bytes to the HTTP parser, which turns them into two JavaScript objects that your callback function receives: req (an IncomingMessage) and res (a ServerResponse).

Both req and res are streams, not plain objects with all their data pre-loaded. req is a readable stream: the method, URL, and headers arrive immediately, but the request body (if any, such as a POST payload) arrives later as a sequence of data events, chunk by chunk, followed by an end event. res is a writable stream: you write a status code and headers once, then write the body (possibly in pieces), then call res.end() to signal you are done. Nothing is sent to the client until you write to it.

Crucially, none of this blocks. Accepting a connection, parsing headers, and reading the body are all handled asynchronously by libuv’s event loop and (for some lower-level socket operations) its thread pool. Your request-handler callback runs on Node’s single JavaScript thread, so whatever code you write inside it executes synchronously with respect to that one request — but while it waits for I/O (reading a file, querying a database), the event loop is free to service other connections. This is why a Node server can comfortably handle thousands of idle-but-connected clients: each one costs a small amount of memory for its socket and stream buffers, not a dedicated OS thread.

A single call to createServer() can serve every route in your application. There is no built-in router — you inspect req.url and req.method yourself and decide what to do. That is precisely why frameworks like Express exist: they add routing, middleware, and body parsing on top of this same low-level API.

Syntax

const { createServer } = require("node:http");

const server = createServer((req, res) => {
  // handle the request here
});

server.listen(port, hostname, backlog, callback);
Part Meaning
createServer(listener) Creates an http.Server instance. listener runs once per incoming request.
req.method The HTTP verb: "GET", "POST", "PUT", "DELETE", etc.
req.url The path and query string exactly as sent, e.g. /api/greet?name=Ana.
req.headers An object of lower-cased request headers.
res.writeHead(status, headers) Sends the status line and headers. Must be called before any body is written.
res.write(chunk) Writes a piece of the response body. Can be called multiple times.
res.end([data]) Finishes the response, optionally writing a final chunk first. Required for every request path.
server.listen(port, cb) Starts accepting connections on port; cb runs once the socket is bound.

Examples

Example 1: A minimal server

const { createServer } = require("node:http");

const server = createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("Hello, world!\n");
});

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

Every request, no matter the URL or method, gets the same 200 response with a plain-text body. res.writeHead sets the status and content type; res.end writes the final chunk and closes the response out. The listen callback fires once the OS has bound the socket, which is why it’s the right place to log that the server is ready.

Example 2: Routing by URL and method

const { createServer } = require("node:http");
const { URL } = require("node:url");

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

  if (url.pathname === "/" && req.method === "GET") {
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("Welcome to the home page\n");
    return;
  }

  if (url.pathname === "/api/greet" && req.method === "GET") {
    const name = url.searchParams.get("name") ?? "stranger";
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ message: `Hello, ${name}!` }));
    return;
  }

  res.writeHead(404, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error: "Not found" }));
});

server.listen(3000, () => {
  console.log("Server running at http://localhost:3000/");
});
# GET /api/greet?name=Ana
{"message":"Hello, Ana!"}

# GET /nope
{"error":"Not found"}

req.url only ever contains a path and query string, never a full URL, so wrapping it in new URL() with a base gives you convenient access to pathname and searchParams. Every branch returns after calling res.end() so no path can accidentally fall through and try to send a second response. Any route that doesn’t match falls through to a 404 JSON response — this is exactly the manual routing that frameworks like Express automate.

Example 3: Reading a JSON request body

const { createServer } = require("node:http");

const server = createServer((req, res) => {
  if (req.method === "POST" && req.url === "/echo") {
    const chunks = [];

    req.on("data", (chunk) => {
      chunks.push(chunk);
    });

    req.on("end", () => {
      const body = Buffer.concat(chunks).toString("utf8");
      let parsed;
      try {
        parsed = JSON.parse(body);
      } catch {
        res.writeHead(400, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ error: "Invalid JSON" }));
        return;
      }
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ youSent: parsed }));
    });

    req.on("error", (err) => {
      console.error("Request error:", err);
      res.writeHead(500, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: "Server error" }));
    });

    return;
  }

  res.writeHead(404, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error: "Not found" }));
});

server.listen(3000);
curl -X POST http://localhost:3000/echo \
  -H "Content-Type: application/json" \
  -d '{"hello":"world"}'

# {"youSent":{"hello":"world"}}

Because req is a stream, the body doesn’t exist all at once — it arrives as Buffer chunks over one or more data events. You collect them into an array and concatenate them once end fires, only then is it safe to parse. This is also the pattern body-parsing middleware (like express.json()) implements internally, just with more edge-case handling.

How It Works Step by Step

  • The process calls server.listen(3000). Node asks the OS to bind and listen on a TCP socket; this is asynchronous, so the listening event (or your callback) fires once it succeeds.
  • A client opens a TCP connection. libuv accepts it and Node emits a connection event internally, then begins parsing bytes as they arrive.
  • Once the request line and headers are fully parsed, Node constructs the req and res objects and invokes your listener callback — synchronously, on the main thread.
  • If your handler reads the body, it registers data/end listeners and then returns immediately, freeing the event loop to process other connections while bytes continue streaming in.
  • Once you call res.writeHead(), the status line and headers are buffered to be sent. Calling res.write() or res.end() flushes data to the socket.
  • res.end() tells Node the response is complete; if the client sent Connection: keep-alive (the default in HTTP/1.1), the socket stays open for the next request instead of closing.

Common Mistakes

Mistake 1: Never calling res.end()

If you write headers but never end the response, the connection hangs open forever and the client’s request never completes:

const server = createServer((req, res) => {
  if (req.url === "/slow") {
    res.writeHead(200, { "Content-Type": "text/plain" });
    // no res.end() call here — the client waits forever
  }
});

Every code path through your handler must eventually call res.end() exactly once. Calling it twice throws ERR_STREAM_WRITE_AFTER_END, which is why the routing examples above always return immediately after ending the response.

Mistake 2: Blocking the event loop with synchronous I/O

Using a synchronous filesystem call inside a request handler blocks the entire process — every other in-flight request has to wait until the file finishes reading:

const fs = require("node:fs");

const server = createServer((req, res) => {
  const data = fs.readFileSync("./big-file.txt"); // blocks every connection
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end(data);
});

Use the promise-based or callback API instead so the event loop stays free to serve other clients while the disk read happens in the background:

const fs = require("node:fs/promises");

const server = createServer((req, res) => {
  fs.readFile("./big-file.txt")
    .then((data) => {
      res.writeHead(200, { "Content-Type": "text/plain" });
      res.end(data);
    })
    .catch((err) => {
      console.error(err);
      res.writeHead(500, { "Content-Type": "text/plain" });
      res.end("Server error\n");
    });
});

Mistake 3: Ignoring the server’s own error event

If the port is already in use, listen() fails asynchronously by emitting an error event, not by throwing. Without a listener, that error crashes the process with an unhandled exception:

server.on("error", (err) => {
  if (err.code === "EADDRINUSE") {
    console.error("Port 3000 is already in use");
  } else {
    console.error("Server error:", err);
  }
});

Best Practices

  • Always pair res.writeHead() with a matching res.end() on every code path, including error branches.
  • Register an error listener on both the server and on req — a malformed or aborted request can emit errors that otherwise crash the process.
  • Prefer the promise-based fs/promises, database drivers, and other async APIs inside handlers; never call a *Sync function on a per-request path.
  • Set Content-Type explicitly — Node does not infer it for you.
  • Read process.env.PORT ?? 3000 instead of hardcoding the port, so the server is deployable behind a platform that assigns its own port.
  • For anything beyond a handful of routes, reach for a framework like Express; it removes the manual if/else routing shown here without hiding how the underlying req/res objects work.
  • Stream large responses (files, big JSON arrays) with res.write() or pipeline() instead of building the whole body in memory first.

Practice Exercises

  • Build a server with three routes: GET / returns plain text, GET /time returns the current server time as JSON, and any other path returns a 404 JSON error.
  • Extend the POST /echo example so it rejects bodies larger than 1KB with a 413 status code, counting bytes as data events arrive.
  • Add a server.on("error", ...) handler that retries on a different port if the original port is taken, logging which port it ultimately bound to.

Summary

  • http.createServer() creates a server whose listener callback runs once per request with req (readable stream) and res (writable stream) arguments.
  • There is no built-in router — you branch on req.method and req.url (parsed with URL) yourself.
  • Request bodies arrive as chunked Buffer data over the data/end events, not as a single ready-made value.
  • Every response must call res.end() exactly once, on every code path.
  • Never use synchronous I/O inside a request handler — it blocks every concurrent connection on the single JavaScript thread.
  • Listen for error events on the server and on req so failures don’t crash the process silently.