Node.js HTTP Server

Build a web server with Node's built-in http module: handle requests and send responses, set status codes and headers, route by method and URL, read query strings and JSON bodies, and call other servers with fetch.

Lesson 9 of 43Intermediate18 min read

Every website and API runs on HTTP, the protocol browsers and apps use to ask servers for things. A client sends a request, made of a method (such as GET or POST), a URL, headers and sometimes a body. The server sends back a response: a status code (such as 200 OK or 404 Not Found), headers, and a body containing HTML, JSON, an image or anything else.

Node.js was designed for exactly this job. Its built-in http module can create a working web server in a few lines, with no packages to install. Each incoming request triggers your handler function with two objects: req, describing the request, and res, which you use to build the response. Because Node is non-blocking, one server process can handle many requests at the same time.

Most real projects use a framework such as Express, covered in the next lesson, but frameworks are built on the http module. Understanding it first means you'll know exactly what the framework is doing for you. In this lesson you'll create a server, route requests by method and path, read query strings and JSON bodies, return proper status codes and headers, and make requests to other servers with fetch.

Creating a server

JavaScript
import { createServer } from 'node:http';

const server = createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
  res.end('Hello from Node!');
});

server.listen(3000, () => {
  console.log('Listening on http://localhost:3000');
});
  • The handler runs once for every request.
  • res.writeHead(status, headers) sets the status code and headers; res.end(body) sends the body and finishes the response.
  • Every request must end with res.end(), or the browser waits until it times out.
  • server.listen(port) starts accepting connections. Stop the server with Ctrl+C.

The request: method, URL and headers

  • req.method is the HTTP method: GET to read, POST to create, PUT or PATCH to update, DELETE to remove.
  • req.url is the path and query string, such as /products?search=lamp. Parse it with new URL(req.url, 'http://localhost') to get pathname and searchParams.
  • req.headers is an object of request headers, with lower-case names: req.headers['content-type'].
  • The request body arrives in chunks, because req is a stream. Collect the chunks, join them and parse the JSON:
JavaScript
let body = '';
for await (const chunk of req) body += chunk;
const data = JSON.parse(body);

Responses and status codes

CodeMeaningTypical use
200OKSuccessful GET
201CreatedSuccessful POST that created something
204No ContentSuccessful DELETE
400Bad RequestInvalid input
404Not FoundUnknown path or item
405Method Not AllowedWrong method for a path
500Internal Server ErrorSomething broke on the server

For JSON, set Content-Type: application/json and send JSON.stringify(data). Node 18 and later also include fetch, so your server can call other APIs exactly as a browser would.

Building a response step by step

writeHead sets everything in one call, but you can also build a response gradually, which is handy when different parts of your code decide different things:

JavaScript
res.statusCode = 302;
res.setHeader('Location', '/login');
res.end();
  • res.statusCode sets the status; it's 200 if you never change it.
  • res.setHeader(name, value) adds one header, and res.getHeader(name) reads it back. Headers set this way are merged with any passed to writeHead.
  • res.headersSent is false until the first part of the response goes out, then true. After that point you can't change the status or headers, so error handlers check it before trying to send an error page.
  • The example above is a redirect: a 3xx status plus a Location header tells the browser to request a different URL. Use 301 for a permanent move and 302 or 307 for a temporary one.

Node.js Web Syntax

http module

import { createServer } from 'node:http';
const server = createServer(async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  if (req.method === 'GET' && url.pathname === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    return res.end('<h1>Home</h1>');
  }
  res.writeHead(404).end('Not found');
});
server.listen(process.env.PORT ?? 3000);
const r = await fetch('https://api.example.com/data');
const data = await r.json();

Node.js Web Summary

  • createServer((req, res) => ...) handles every request; listen(port) starts the server.
  • req.method, req.url and req.headers describe the request; the body arrives as a stream.
  • res.writeHead sets the status and headers; res.end sends the body and must always be called.
  • Use correct status codes: 200, 201, 204, 400, 404, 405, 500.
  • fetch is built into Node for calling other servers.

Node.js Web Examples

Each example shows the code, the output it produces and what each part does.

Example 1

A server with routes and JSON

Node.js
import { createServer } from 'node:http';

const products = [
  { id: 1, name: 'Mug', price: 8.5 },
  { id: 2, name: 'Lamp', price: 24 },
];

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

const server = createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost');
  if (req.method === 'GET' && url.pathname === '/products') {
    const q = (url.searchParams.get('search') ?? '').toLowerCase();
    return sendJson(res, 200, products.filter((p) => p.name.toLowerCase().includes(q)));
  }
  const match = url.pathname.match(/^\/products\/(\d+)$/);
  if (req.method === 'GET' && match) {
    const product = products.find((p) => p.id === Number(match[1]));
    return product ? sendJson(res, 200, product) : sendJson(res, 404, { error: 'Not found' });
  }
  sendJson(res, 404, { error: 'Unknown route' });
});

server.listen(0, async () => {
  const base = `http://localhost:${server.address().port}`;
  for (const path of ['/products', '/products?search=la', '/products/2', '/products/9', '/nope']) {
    const r = await fetch(base + path);
    console.log(r.status, path, JSON.stringify(await r.json()));
  }
  server.close();
});
Output
200 /products [{"id":1,"name":"Mug","price":8.5},{"id":2,"name":"Lamp","price":24}]
200 /products?search=la [{"id":2,"name":"Lamp","price":24}]
200 /products/2 {"id":2,"name":"Lamp","price":24}
404 /products/9 {"error":"Not found"}
404 /nope {"error":"Unknown route"}
Line-by-line explanation
  1. Lines 8 to 11A helper sets the JSON header and sends the data.
  2. Line 14URL splits req.url into a pathname and search parameters.
  3. Line 16A missing search parameter becomes an empty string, which matches everything.
  4. Line 19A regular expression extracts the id from paths like /products/2.
  5. Line 27Port 0 picks any unused port; the demo then calls its own server with fetch.
Example 2

Reading a JSON request body

Node.js
import { createServer } from 'node:http';

const orders = [];

async function readJson(req) {
  let body = '';
  for await (const chunk of req) body += chunk;
  return JSON.parse(body);
}

const server = createServer(async (req, res) => {
  if (req.url !== '/orders') return res.writeHead(404).end();
  if (req.method !== 'POST') {
    return res.writeHead(405, { Allow: 'POST' }).end();
  }
  try {
    const data = await readJson(req);
    if (!data.item || !(data.qty > 0)) {
      res.writeHead(400, { 'Content-Type': 'application/json' });
      return res.end(JSON.stringify({ error: 'item and a positive qty are required' }));
    }
    const order = { id: orders.length + 1, ...data };
    orders.push(order);
    res.writeHead(201, { 'Content-Type': 'application/json', Location: `/orders/${order.id}` });
    res.end(JSON.stringify(order));
  } catch {
    res.writeHead(400).end('Invalid JSON');
  }
});

server.listen(0, async () => {
  const url = `http://localhost:${server.address().port}/orders`;
  const post = (body) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body });

  let r = await post(JSON.stringify({ item: 'Mug', qty: 2 }));
  console.log(r.status, r.headers.get('location'), await r.text());
  r = await post(JSON.stringify({ item: 'Lamp', qty: 0 }));
  console.log(r.status, await r.text());
  r = await post('{not json');
  console.log(r.status, await r.text());
  r = await fetch(url);
  console.log(r.status, r.headers.get('allow'));
  server.close();
});
Output
201 /orders/1 {"id":1,"item":"Mug","qty":2}
400 {"error":"item and a positive qty are required"}
400 Invalid JSON
405 POST
Line-by-line explanation
  1. Lines 5 to 9The body arrives in chunks; for await collects them before parsing.
  2. Lines 13 to 15Wrong method gets 405 with an Allow header listing what is accepted.
  3. Lines 18 to 21Input is validated before anything is saved.
  4. Line 24201 Created with a Location header pointing to the new order.
  5. Lines 26 to 27Invalid JSON throws in JSON.parse and becomes a 400 response.
Example 3

Calling another API with fetch

Node.js
import { createServer } from 'node:http';

const ratesApi = createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ base: 'GBP', rates: { EUR: 1.17, USD: 1.27 } }));
});

ratesApi.listen(0, async () => {
  const url = `http://localhost:${ratesApi.address().port}/latest`;
  try {
    const response = await fetch(url, { signal: AbortSignal.timeout(3000) });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const { rates } = await response.json();
    const pounds = 50;
    console.log(`£${pounds} = €${(pounds * rates.EUR).toFixed(2)} = $${(pounds * rates.USD).toFixed(2)}`);
  } catch (err) {
    console.log(`Rates unavailable: ${err.message}`);
  } finally {
    ratesApi.close();
  }
});
Output
£50 = €58.50 = $63.50
Line-by-line explanation
  1. Lines 3 to 6A tiny local server stands in for a real exchange-rate API.
  2. Line 11fetch is built into Node; the signal adds a three-second timeout.
  3. Line 12fetch doesn't throw for 404 or 500, so check response.ok yourself.
  4. Line 13response.json() reads and parses the body.
  5. Line 19finally closes the demo server whether the request worked or not.

Common Node.js Web Mistakes

Forgetting res.end

Don't
createServer((req, res) => {
  res.writeHead(200);
  res.write('Hello');             // the response never finishes; the browser keeps waiting
});
Do
createServer((req, res) => {
  res.writeHead(200);
  res.end('Hello');
});

Sending an object directly

Don't
res.end({ ok: true });            // TypeError: the chunk must be a string or Buffer
Do
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));

Assuming fetch throws on HTTP errors

Don't
const data = await (await fetch(url)).json();   // a 500 error page is parsed as if it were data
Do
const response = await fetch(url);
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
const data = await response.json();

Node.js Web in Real Projects

The http module in practice

Express, Fastify, Koa and Next.js all sit on top of Node's http module. Developers use it directly for tiny services such as health-check endpoints, webhooks receivers and test servers that fake an external API, exactly like the demos in this lesson. fetch is used throughout Node back ends to call payment providers, email services and other internal APIs.

Node.js Web FAQ

How do I fix "EADDRINUSE: address already in use" in Node.js?

Another program, often an older copy of your own server, is already listening on that port. Stop it (check other terminal windows, or find the process with lsof -i :3000 on macOS and Linux), or start your server on a different port. In code, listen for the server's 'error' event and check err.code === 'EADDRINUSE' to print a clear message.

How do I stop a Node.js HTTP server gracefully?

Call server.close(). It stops accepting new connections and calls its callback once the existing ones have finished, so requests in progress aren't cut off. Servers usually do this when they receive SIGTERM, then close database connections and let the process exit.

How do I serve an HTML file with the http module?

Read the file and send it with the right content type: res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) followed by res.end(await readFile(file)). For several files, map extensions to content types and never build the path directly from req.url without checking it. Frameworks such as Express do this for you with express.static.

How do I allow CORS requests to a plain Node.js server?

Add an Access-Control-Allow-Origin header naming the site that may call you, such as https://app.example, to each response. For requests with JSON bodies or custom headers, the browser first sends an OPTIONS request, so answer it with 204 plus Access-Control-Allow-Methods and Access-Control-Allow-Headers. Avoid * for APIs that use cookies or other credentials.

Do I need the https module to run a production server?

Usually not. Most hosting platforms, load balancers and reverse proxies such as Nginx handle HTTPS in front of your app and forward plain HTTP to it. You only need node:https with your own certificate when Node itself must accept encrypted connections directly, for example on a server with no proxy in front.

Node.js Web Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the line to finish the response.

res.writeHead(200, { 'Content-Type': 'text/plain' });
res.('OK');
Multiple choiceeasy

Which status code means "Not Found"?

Multiple choicemedium

What does new URL(req.url, 'http://localhost').searchParams.get('page') return for /list?page=3?

Predict the outputmedium

A POST successfully creates a new item. Which status code should the server send?

Multiple choicehard

Why does reading a request body need for await (const chunk of req)?

Node.js Web Quiz

5 questions to check what you learned.

  1. Which built-in module creates a web server?

    • node:net only
    • node:web
    • node:http
    • node:server
  2. Which HTTP method is usually used to create a resource?

    • GET
    • DELETE
    • HEAD
    • POST
  3. Which header tells the client that the body is JSON?

    • Accept: application/json
    • Body-Type: json
    • Content-Type: application/json
    • Format: json
  4. Does fetch throw an error when the server responds with 500?

    • No, check response.ok or response.status
    • Yes
    • Only for POST requests
    • Only in Node
  5. What does status 405 mean?

    • The resource moved
    • The method isn't allowed for this URL
    • The server is overloaded
    • Authentication is required