Understanding HTTP by Building an API From Scratch

Oct 29, 2026

Most of us use HTTP every day without ever looking at it. We call fetch(), get JSON back, and move on. But when something breaks (a CORS error, a mysterious 301, a request that hangs), it helps to know what is actually happening on the wire.

In this article we'll build an API in Node.js, starting from raw TCP sockets and working our way up to the built-in http module. Along the way, we'll follow a single request from the moment you hit Enter in the browser to the moment the response is rendered, covering DNS, TCP, TLS, HTTP, server processing, the response, and what the browser does with it.

You'll need Node.js 18+ and a terminal. No frameworks, no dependencies.

Before You Start: Set Up the Project

You'll create a handful of small files in one folder and run each with node. Start by making the folder and telling Node to use modern import syntax:

mkdir http-from-scratch
cd http-from-scratch
npm init -y
npm pkg set type=module
node --version   # should print v18 or higher

Here's every file you'll create, in order:

FileStepWhat it does
dns-lookup.js1Resolves a hostname to an IP address
raw-tcp.js2A TCP server that prints the raw bytes it receives
https-server.js3The same idea over TLS
hand-rolled.js4A TCP server that replies with a hand-written HTTP response
server.js5The real notes API built on node:http
index.html8A tiny page that calls the API from another origin to test CORS

A few conventions make it easier to follow along:

  • Every code block starts with a comment naming its file. Create that file and paste the code in.
  • The command to run it comes right after the code. Run commands from the http-from-scratch folder.
  • The servers all use port 3000, so press Ctrl+C to stop one before starting the next.
  • Commands starting with curl go in a second terminal while the server runs in the first. On Windows PowerShell, use curl.exe instead of curl, or use Git Bash or WSL.

The Big Picture

When you type https://api.example.com/users/1 into a browser, roughly this happens:

  1. DNS: the hostname is resolved to an IP address.
  2. TCP: the client opens a reliable connection to that IP on port 443.
  3. TLS: client and server negotiate encryption and verify identity.
  4. HTTP request: the client sends a text message describing what it wants.
  5. Server processing: the server parses, routes, runs logic, and builds a reply.
  6. HTTP response: the server sends back a status, headers, and a body.
  7. Browser/runtime behavior: the client interprets the response, caches it, enforces security rules, and renders or hands it to your code.

We'll build the server side of steps 4 to 6 ourselves, and look closely at how steps 1 to 3 and 7 shape what we write.

Step 1: DNS (Finding the Server)

Computers talk to IP addresses, not names. DNS is the phone book that translates api.example.com into something like 93.184.216.34.

The lookup typically goes: browser cache, then the operating system cache (and the hosts file), then a recursive resolver (usually your ISP or a public one), which walks from the root servers to the TLD servers to the domain's authoritative name server.

Let's see DNS in action. Create a file called dns-lookup.js and paste in this code:

// dns-lookup.js
import dns from 'node:dns/promises';

const result = await dns.lookup('example.com');
console.log(result); // e.g. { address: '93.184.216.34', family: 4 }

Run it:

node dns-lookup.js

The address you get may differ, because large sites change IPs. Now change 'example.com' to 'localhost' and run it again. You'll get 127.0.0.1 (or ::1) instantly, with no network lookup at all.

For our local project, DNS is almost invisible: localhost resolves to 127.0.0.1 (or ::1 for IPv6) straight from your hosts file. That's worth remembering, because it's one reason local development feels instant and production doesn't. In the real world, DNS adds latency to the first request, and TTL values decide how long answers are cached.

Step 2: TCP (Opening a Connection)

Once the client has an IP address, it opens a TCP connection through the three-way handshake: SYN, SYN-ACK, ACK. TCP gives us an ordered, reliable stream of bytes, and that's all it gives us. It knows nothing about requests or responses.

To prove this, let's write a server that speaks no HTTP at all. It just accepts connections and prints whatever bytes arrive. Create raw-tcp.js and paste this in:

// raw-tcp.js
import net from 'node:net';

const server = net.createServer((socket) => {
  socket.on('data', (chunk) => {
    console.log('--- received ---');
    console.log(chunk.toString());
  });
});

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

Start it from your project folder:

node raw-tcp.js

Now open http://localhost:3000 in your browser (the page will hang, which is expected). In your terminal you'll see something like:

GET / HTTP/1.1
Host: localhost:3000
Connection: keep-alive
User-Agent: Mozilla/5.0 ...
Accept: text/html,application/xhtml+xml,...
Accept-Language: en-US,en;q=0.9

Prefer the terminal? In a second terminal run curl http://localhost:3000. You'll see a similar request, with a User-Agent of curl. Curl will hang waiting for a reply, so press Ctrl+C there, then press Ctrl+C in the server's terminal to stop it.

That's it. HTTP is just text sent over a TCP connection, following agreed-upon rules. Everything else in this article is a refinement of that idea.

Step 3: TLS (Making It Private)

For https:// URLs, a TLS handshake happens after TCP and before any HTTP. In simplified form:

  • The client says which TLS versions and cipher suites it supports.
  • The server replies with its certificate, which proves it owns the domain and is signed by an authority the client trusts.
  • Both sides derive shared session keys, and from then on everything is encrypted.

The key insight is that HTTP doesn't change. The same text request travels inside an encrypted tunnel. Your server code is identical whether it's behind TLS or not. In production, TLS is often terminated by a reverse proxy or load balancer (Nginx, Caddy, a cloud load balancer) so your Node process only sees plain HTTP.

To try TLS locally you need a certificate, and OpenSSL can create a self-signed one. It's already installed on macOS and most Linux systems, and it's included with Git Bash on Windows. Run this from your project folder:

openssl req -x509 -newkey rsa:2048 -nodes -keyout localhost-key.pem -out localhost.pem -days 30 -subj "/CN=localhost"

This creates two files: localhost.pem (the certificate) and localhost-key.pem (the private key). Because you signed the certificate yourself, browsers and curl won't trust it by default. That's fine for a local test, and we'll tell curl to accept it with the -k flag.

Next, create https-server.js. It uses https.createServer in place of http.createServer, and the rest is the same idea:

// https-server.js
import https from 'node:https';
import fs from 'node:fs';

const options = {
  key: fs.readFileSync('localhost-key.pem'),
  cert: fs.readFileSync('localhost.pem'),
};

const server = https.createServer(options, (req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Hello over TLS\n');
});

server.listen(3443, () => console.log('Listening on https://localhost:3443'));

Run it:

node https-server.js

Then, in a second terminal, call it. The -k tells curl to accept your self-signed certificate:

curl -vk https://localhost:3443

In the output, look for the lines about the TLS handshake and the certificate before the GET line. Encryption is set up first, and HTTP starts only after it. At the end you should see Hello over TLS. Press Ctrl+C to stop the server when you're done.

If Node says ENOENT or can't find the .pem files, you're running it from a different folder than the one holding them, so cd into the folder that contains localhost.pem and localhost-key.pem and try again.

Want browsers to trust the certificate without a warning? Tools like mkcert create locally trusted certificates, but they need extra setup, so we're skipping them here.

We'll stay on plain HTTP for the rest of the tutorial to keep the focus on the protocol itself.

Step 4: HTTP by Hand

An HTTP/1.1 request has three parts: a request line, headers, and an optional body. A response has a status line, headers, and an optional body. Lines end with \r\n, and a blank line separates the headers from the body.

Stop the previous server with Ctrl+C, then create hand-rolled.js. It's the same raw TCP server, but now it replies with a valid HTTP response:

// hand-rolled.js
import net from 'node:net';

const server = net.createServer((socket) => {
  socket.on('data', (chunk) => {
    const text = chunk.toString();
    const [requestLine] = text.split('\r\n');
    const [method, path, version] = requestLine.split(' ');
    console.log({ method, path, version });

    const body = JSON.stringify({ message: 'Hello from raw TCP' });

    socket.write(
      'HTTP/1.1 200 OK\r\n' +
      'Content-Type: application/json\r\n' +
      `Content-Length: ${Buffer.byteLength(body)}\r\n` +
      'Connection: close\r\n' +
      '\r\n' +
      body
    );
    socket.end();
  });
});

server.listen(3000);

Start the server:

node hand-rolled.js

Then, in a second terminal, make a request with curl -i, which prints the response headers too:

curl -i http://localhost:3000

You should see:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 32
Connection: close

{"message":"Hello from raw TCP"}

That's a working HTTP response that we assembled by hand. The server's terminal also logs the parsed method, path, and version, and opening http://localhost:3000 in your browser shows the same JSON.

Two details matter here. Content-Length tells the client where the body ends. Without it (or chunked encoding), the client wouldn't know when to stop reading. And the blank line (\r\n\r\n) is the boundary between metadata and content.

This works, but it's fragile. We aren't handling partial packets, request bodies, or malformed input. That's exactly the work Node's http module does for us.

Step 5: The Real API with node:http

Now let's build a small in-memory notes API. Stop the previous server with Ctrl+C, then create server.js and paste in the code below. The notes live in memory, so they reset every time you restart the server.

// server.js
import http from 'node:http';
import { randomUUID } from 'node:crypto';

const notes = new Map();

function sendJson(res, status, data) {
  const body = JSON.stringify(data);
  res.writeHead(status, {
    'Content-Type': 'application/json; charset=utf-8',
    'Content-Length': Buffer.byteLength(body),
  });
  res.end(body);
}

async function readJsonBody(req) {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const raw = Buffer.concat(chunks).toString();
  return raw ? JSON.parse(raw) : {};
}

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

  try {
    // GET /notes
    if (method === 'GET' && pathname === '/notes') {
      return sendJson(res, 200, [...notes.values()]);
    }

    // POST /notes
    if (method === 'POST' && pathname === '/notes') {
      const { text } = await readJsonBody(req);
      if (!text) return sendJson(res, 400, { error: 'text is required' });

      const note = { id: randomUUID(), text, createdAt: new Date().toISOString() };
      notes.set(note.id, note);
      res.setHeader('Location', `/notes/${note.id}`);
      return sendJson(res, 201, note);
    }

    // GET /notes/:id
    const match = pathname.match(/^\/notes\/([\w-]+)$/);
    if (match) {
      const note = notes.get(match[1]);

      if (method === 'GET') {
        return note
          ? sendJson(res, 200, note)
          : sendJson(res, 404, { error: 'Note not found' });
      }

      if (method === 'DELETE') {
        if (!note) return sendJson(res, 404, { error: 'Note not found' });
        notes.delete(note.id);
        res.writeHead(204);
        return res.end();
      }

      res.setHeader('Allow', 'GET, DELETE');
      return sendJson(res, 405, { error: 'Method not allowed' });
    }

    sendJson(res, 404, { error: 'Route not found' });
  } catch (err) {
    if (err instanceof SyntaxError) {
      return sendJson(res, 400, { error: 'Invalid JSON' });
    }
    console.error(err);
    sendJson(res, 500, { error: 'Internal server error' });
  }
});

server.listen(3000, () => console.log('API running on http://localhost:3000'));

Start the server:

node server.js

Then, in a second terminal, send some requests:

# Create a note
curl -i -X POST http://localhost:3000/notes \
  -H 'Content-Type: application/json' \
  -d '{"text": "Learn HTTP"}'

# List notes
curl -i http://localhost:3000/notes

# Delete one (use an id from above)
curl -i -X DELETE http://localhost:3000/notes/<id>

The POST should return 201 Created with a Location header and the new note as JSON. Copy that note's id into the DELETE command, which should answer 204 No Content with an empty body.

Now try the error paths and watch the status codes change:

# Missing field -> 400 Bad Request
curl -i -X POST http://localhost:3000/notes \
  -H 'Content-Type: application/json' \
  -d '{}'

# Broken JSON -> 400 Bad Request
curl -i -X POST http://localhost:3000/notes \
  -H 'Content-Type: application/json' \
  -d '{oops'

# Unknown note -> 404 Not Found
curl -i http://localhost:3000/notes/does-not-exist

# Wrong method -> 405 Method Not Allowed
curl -i -X PUT http://localhost:3000/notes/abc

Leave server.js running or keep it handy, because we'll come back to it in Step 8.

This small file touches nearly every core HTTP concept. Let's unpack them.

Methods and Routing

The method (GET, POST, DELETE, ...) says what you want to do; the path says what you want to do it to. Routing is simply matching those two values to a handler. Frameworks like Express add nicer syntax (app.get('/notes/:id', ...)), but underneath it's the same if statements, just organized.

Methods carry semantics. GET should be safe (no side effects). PUT and DELETE should be idempotent (doing them twice has the same effect as once). POST is neither. Caches, proxies, and browsers rely on these promises.

Headers

Headers are key-value metadata attached to requests and responses. A few worth knowing:

HeaderDirectionPurpose
HostRequestWhich site you're talking to (lets one server host many domains)
Content-TypeBothThe format of the body, such as application/json
Content-LengthBothSize of the body in bytes
AcceptRequestWhich formats the client can handle
AuthorizationRequestCredentials, such as a bearer token
LocationResponseWhere a new or moved resource lives
Cache-ControlBothCaching rules
Set-Cookie / CookieResponse / RequestState across requests

Notice that our POST handler sets Location and Content-Type. Those aren't decoration. Clients act on them.

Status Codes

The first digit tells you the category:

  • 2xx: success (200 OK, 201 Created, 204 No Content)
  • 3xx: redirection (301 Moved Permanently, 304 Not Modified)
  • 4xx: the client made a mistake (400 Bad Request, 401 Unauthorized, 404 Not Found, 405 Method Not Allowed)
  • 5xx: the server failed (500 Internal Server Error, 503 Service Unavailable)

Picking accurate codes is one of the cheapest ways to make an API pleasant to use. A 404 for a missing note and a 400 for invalid JSON let clients react differently without parsing error messages.

The Body

Request bodies arrive as a stream of chunks, which is why readJsonBody collects them before parsing. This mirrors reality: the body may be larger than a single TCP packet, and it may arrive slowly. In a production API you'd also cap the maximum size to avoid memory exhaustion.

Step 6: What the Server Does Between Request and Response

It's worth tracing what happens inside the Node process:

  1. The OS kernel accepts the TCP connection and hands it to Node's event loop (via libuv).
  2. Node's HTTP parser (llhttp) reads the bytes and builds req, with the method, URL, headers, and a readable body stream.
  3. Your handler runs. If it does asynchronous work such as database queries or API calls, the event loop is free to serve other connections in the meantime.
  4. You write to res. Node serializes the status line and headers, then streams the body.
  5. The connection either closes or stays open for reuse (keep-alive).

A useful consequence: a single slow synchronous operation blocks every client, because Node runs your JavaScript on one thread. A while loop or a heavy JSON.parse on a huge payload stalls the whole server. Keep handlers async and light, and move CPU-heavy work elsewhere.

Step 7: The Response Travels Back

The response retraces the path in reverse: Node writes bytes to the socket, TLS encrypts them (if present), TCP splits them into packets and handles retransmission, and the network delivers them to the client.

You can inspect the response yourself with curl -v, which shows both the request and response headers, or the Network tab in your browser's dev tools, which also shows timing for DNS lookup, connection, TLS, waiting (time to first byte), and download. That timing breakdown maps directly onto the stages in this article, so it's a great way to see where latency lives.

Try it against your running notes API:

curl -v http://localhost:3000/notes

In the output, lines starting with > are the request your client sent and lines starting with < are the response. Most of this article is visible there: the address it connected to (Trying 127.0.0.1...), the request line and headers, the status line, and the body.

Step 8: Browser and Runtime Behavior

A correct server response is only half the story. The client interprets it, and different clients behave differently.

Content handling: The browser uses Content-Type to decide whether to render HTML, display an image, or download a file. Get this wrong and your JSON shows up as a download, or your HTML as plain text.

Caching: Headers like Cache-Control: max-age=3600 and ETag let browsers reuse responses or revalidate them cheaply with a 304 Not Modified. Forgetting them means unnecessary traffic; setting them carelessly means stale data.

CORS: Browsers (but not curl or Node) enforce the same-origin policy. If your frontend at http://localhost:5173 calls your API at http://localhost:3000, the browser will block the response unless the server opts in. Here's the minimal fix. Add it inside the request handler in server.js, just before the try block:

res.setHeader('Access-Control-Allow-Origin', 'http://localhost:5173');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');

if (req.method === 'OPTIONS') {   // preflight request
  res.writeHead(204);
  return res.end();
}

A very common source of confusion: the request often does reach your server and succeeds. It's the browser that refuses to hand the response to your JavaScript. That's why the same call works in curl and fails in the browser.

To see this for yourself, create index.html in your project folder:

<!-- index.html -->
<p>Open the browser console (F12) to see the result.</p>
<script>
  fetch('http://localhost:3000/notes')
    .then((res) => res.json())
    .then((notes) => console.log('Notes:', notes))
    .catch((err) => console.error('Request failed:', err));
</script>

Serve it from a different port than your API. If you have Python installed:

python3 -m http.server 5173

Open http://localhost:5173 with server.js running. Without the CORS headers, the console shows a CORS error, yet the Network tab shows your server answered. Add the headers from the snippet above, restart server.js, and reload the page: the notes appear.

Connection reuse: Browsers keep connections open and reuse them (and with HTTP/2 or HTTP/3, multiplex many requests over one), which is why the expensive DNS, TCP, and TLS steps usually only happen for the first request to a host.

Runtime differences: fetch in the browser, fetch in Node, and curl all produce HTTP requests, but they differ in default headers, cookie handling, redirect behavior, and security rules. When something works in one and not another, compare the actual headers on the wire.

Putting It All Together

Here's the whole journey for GET /notes/abc123:

  1. The browser checks its caches, then DNS resolves the hostname to an IP.
  2. TCP handshake opens a connection.
  3. TLS handshake encrypts it.
  4. The browser sends GET /notes/abc123 HTTP/1.1 plus headers.
  5. Node parses the request, your router matches the path, and your handler looks up the note.
  6. The server replies 200 OK with Content-Type: application/json and a JSON body.
  7. The browser checks CORS and caching rules, then passes the data to your JavaScript.

None of it is magic. It's text, over a socket, with conventions that everyone agreed to follow.

Where to Go Next

  • Read the HTTP semantics spec (RFC 9110) for the real definitions of methods and status codes.
  • Add input validation, authentication, and rate limiting to the notes API.
  • Explore what HTTP/2 and HTTP/3 change (multiplexing, header compression, QUIC instead of TCP).
  • Rebuild the same API in Express or Fastify and notice how much of what they provide you now understand.

The best way to demystify a protocol is to talk to it directly.