Handling Requests and Routes

A raw node:http server does not know what a "route" is. Every request that hits your server — whether it is GET /, POST /users, or GET /users/42 — arrives at the exact same callback function. There is no built-in path matcher, no automatic 404 page, and no concept of "this URL maps to this handler." All of that behavior is something you build by reading the request’s method and URL and branching on them yourself. This lesson shows exactly how a request reaches your code, what information you have available to route it, and how to build a small, dependable router from nothing but the standard library.

Overview / How it works

When you call http.createServer(callback), Node registers that callback to run once for every incoming HTTP request. Under the hood, libuv accepts the TCP connection, Node’s HTTP parser reads the raw bytes off the socket, and once a complete set of request headers has arrived, the server emits a request event — which is exactly what fires your callback. The callback receives two objects: req, an IncomingMessage (a readable stream) with properties like req.method and req.url, and res, a ServerResponse (a writable stream) that you use to send a status code, headers, and a body back.

Routing is simply: look at req.method and req.url, decide which piece of logic should handle this particular combination, and call it. Node does not strip the query string from req.url — if a client requests /search?q=node, then req.url is the literal string "/search?q=node", not "/search". You must parse it yourself, typically with the global URL class, to separate the pathname (what you match routes against) from the query string (extra parameters).

Because the event loop is single-threaded, your request callback runs to completion (or until it awaits/queues something) before the loop can process other events. If your handler does synchronous, CPU-heavy work — or blocks on something like fs.readFileSync for a large file — every other in-flight request on the server stalls until that handler finishes. This is why routing logic should stay lightweight and delegate real I/O to the async APIs (fs/promises, database drivers, fetch), letting libuv’s thread pool or the OS handle the waiting while the event loop keeps serving other requests.

Finally, every code path through your router must call res.end() exactly once. The HTTP response is a stream; until you end it, the client’s connection sits open waiting for more data. Miss a branch — an unmatched route with no fallback, an error path with no response — and that client hangs until it times out.

Syntax

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

  // req.method   -> "GET", "POST", "PUT", "DELETE", ...
  // requestUrl.pathname   -> "/users/42" (no query string)
  // requestUrl.searchParams -> URLSearchParams for ?key=value pairs

  if (req.method === "GET" && requestUrl.pathname === "/") {
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("OK");
  }
});
Piece Meaning
req.method The HTTP verb the client used: GET, POST, PUT, PATCH, DELETE, etc.
req.url The raw path and query string exactly as sent, e.g. /search?q=node.
new URL(req.url, base) Parses req.url into a structured object; requires a base because req.url alone has no host.
requestUrl.pathname Just the path, e.g. /search — what you match routes against.
requestUrl.searchParams A URLSearchParams instance for reading query parameters with .get(name).
res.writeHead(status, headers) Sends the status line and headers. Must happen before writing the body.
res.end([data]) Writes final data (optional) and closes the response. Required on every path.

Examples

Example 1: A basic multi-route server

The simplest router is a chain of if/else checks on method and path, with a body-reading branch for POST requests and a catch-all 404 at the end.

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

const server = http.createServer((req, res) => {
  const { method, url } = req;

  if (method === "GET" && url === "/") {
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("Welcome to the home page");
  } else if (method === "GET" && url === "/about") {
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("About this server");
  } else if (method === "POST" && url === "/echo") {
    let body = "";
    req.on("data", (chunk) => {
      body += chunk;
    });
    req.on("end", () => {
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ received: body }));
    });
    req.on("error", () => {
      res.writeHead(500, { "Content-Type": "text/plain" });
      res.end("Server error reading request body");
    });
  } else {
    res.writeHead(404, { "Content-Type": "text/plain" });
    res.end("Not Found");
  }
});

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

Output:

Server running at http://localhost:3000/

$ curl http://localhost:3000/
Welcome to the home page

$ curl -X POST -d "hi" http://localhost:3000/echo
{"received":"hi"}

$ curl http://localhost:3000/missing
Not Found

Notice that url === "/about" only matches when the URL is exactly that string — this works here because none of these routes are ever requested with a query string, but it is fragile, as the Common Mistakes section explains.

Example 2: Parsing paths and query strings with a route table

A chain of if/else gets unreadable fast. A lookup object keyed by "METHOD path" scales better, and using URL correctly separates the path from its query string.

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

function homeHandler(req, res) {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("Welcome to the home page");
}

function searchHandler(req, res, query) {
  const term = query.get("q") ?? "";
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ query: term, results: [] }));
}

function notFoundHandler(req, res) {
  res.writeHead(404, { "Content-Type": "text/plain" });
  res.end("Not Found");
}

const routes = {
  "GET /": homeHandler,
  "GET /search": searchHandler,
};

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

  handler(req, res, requestUrl.searchParams);
});

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

Output:

$ curl "http://localhost:3000/search?q=node"
{"query":"node","results":[]}

Because the pathname is parsed out of the URL before it is used as the lookup key, /search?q=node and /search?q=node&page=2 both correctly match the "GET /search" route — the handler then reads whatever query parameters it needs from searchParams separately.

Example 3: Dynamic route segments (like /users/:id)

Real APIs need routes with variable parts. Without a framework, the simplest approach is to store each route as a regular expression and test the pathname against each one in order.

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

function listUsers(req, res) {
  const users = [
    { id: 1, name: "Ada" },
    { id: 2, name: "Grace" },
  ];
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify(users));
}

function getUserById(req, res, match) {
  const id = Number(match[1]);
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ id, name: `User ${id}` }));
}

const routes = [
  { method: "GET", pattern: /^\/users$/, handler: listUsers },
  { method: "GET", pattern: /^\/users\/(\d+)$/, handler: getUserById },
];

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

  for (const route of routes) {
    if (route.method !== req.method) continue;
    const match = route.pattern.exec(requestUrl.pathname);
    if (match) {
      return route.handler(req, res, match);
    }
  }

  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/");
});

Output:

$ curl http://localhost:3000/users/2
{"id":2,"name":"User 2"}

The capture group (\d+) pulls the numeric id straight out of the path. Route order matters here: /^\/users$/ is checked before /^\/users\/(\d+)$/, so a request for the collection and a request for a single user never collide. This same pattern is how frameworks like Express implement /users/:id internally, just with far more polish.

How it works step by step

For a request like GET /users/2 hitting Example 3:

  1. The OS accepts the TCP connection and libuv hands the readable socket data to Node’s HTTP parser.
  2. Once the request line and headers are fully parsed, the server emits request, invoking your callback synchronously with a fresh req/res pair.
  3. new URL(req.url, base) runs synchronously, splitting "/users/2" into a pathname of "/users/2" with no query string.
  4. The for loop walks the routes array in order, testing each regular expression against the pathname until /^\/users\/(\d+)$/ matches.
  5. getUserById runs synchronously, builds a JSON string, and calls res.writeHead then res.end — at this point the response is flushed and the socket may be reused for the next request if keep-alive is active.
  6. If no pattern had matched, the loop would finish without returning, falling through to the 404 res.end at the bottom — this is the fallback branch that guarantees every request gets a response.

None of this involves the timer or setImmediate phases of the event loop unless your handler itself performs async I/O (a database query, a file read); a purely synchronous handler like this one runs start-to-finish in a single tick.

Common Mistakes

Mistake 1: Matching req.url without stripping the query string

Wrong — this silently fails the moment a client adds any query parameter:

if (req.url === "/about") {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("About this server");
}

A request to /about?ref=footer has req.url === "/about?ref=footer", which does not equal "/about", so this route falls through to your 404 handler even though the path is correct. Always parse first:

const pathname = new URL(req.url, `http://${req.headers.host}`).pathname;
if (pathname === "/about") {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("About this server");
}

Mistake 2: No default branch, so unmatched requests hang

If your router only handles the routes you expect and has no final else, a request to any other path never gets res.end() called at all — the connection just sits open until the client times out:

if (pathname === "/") {
  res.end("home");
} else if (pathname === "/about") {
  res.end("about");
}
// nothing else — an unmatched request never gets a response

Always end the chain with an unconditional fallback that writes a 404 (or appropriate status) and calls res.end(), as shown in every example above.

Mistake 3: Calling res.end() more than once

Once a response has ended, writing to it again throws ERR_STREAM_WRITE_AFTER_END. This typically happens when a handler both writes a response inline and falls through to a shared fallback:

if (pathname === "/ping") {
  res.end("pong");
}
res.end("Not Found"); // BUG: runs even when /ping already matched and ended the response

Use return after every res.end() inside a branch (as in Example 3’s router loop) so control never reaches a later res.end() call for the same request.

Best Practices

  • Always parse req.url with new URL(req.url, base) before matching — never compare the raw string, since it can carry a query string or trailing slash you didn’t expect.
  • Match on method and pathname together; the same path often needs different handlers for GET vs. POST vs. DELETE.
  • Always include an unconditional fallback route that sends a 404 (or 405 for a known path with the wrong method) and calls res.end().
  • Return immediately after calling a handler (or after res.end()) so a request can never fall through to a second response.
  • Keep handlers synchronous-fast or properly asynchronous — never block the event loop with a sync call like fs.readFileSync inside a request handler under load.
  • Validate and coerce dynamic route parameters (like the numeric id from a regex capture group) before using them — never trust path segments as safe input for a database query or file path.
  • Set an accurate Content-Type header for every response so clients parse the body correctly.
  • Once your routing table grows past a handful of routes with real needs (middleware, body parsing, error handling), reach for a framework like Express rather than hand-rolling more router logic.

Practice Exercises

  • Extend the router from Example 3 with a DELETE /users/:id route that responds with { "deleted": id } as JSON.
  • Add a GET /health route that responds with { "uptime": process.uptime() } as JSON, and confirm it does not interfere with the existing /users routes.
  • Modify Example 3 so that a request to a known path (/users) with an unsupported method (like PUT) responds with status 405 instead of falling through to the generic 404.

Summary

  • Node’s core http module has no built-in router — you must inspect req.method and req.url yourself and decide which code handles each request.
  • req.url includes the query string; always parse it with new URL(req.url, base) and match routes against .pathname, reading extra data from .searchParams.
  • A lookup table keyed by "METHOD path" scales better than long if/else chains; regular expressions with capture groups handle dynamic segments like /users/:id.
  • Every request must end with exactly one res.end() call — missing it hangs the client, calling it twice throws an error.
  • Keep route handlers non-blocking, since a synchronous stall in one handler freezes every other in-flight request on the same event loop.