HTTP from First Principles
- What HTTP actually is
- A Request is just data
- The Request object
- A Response is also just data
- The Response object
- Status codes are just numbers
- Headers you'll actually use
- Request headers
- Response headers
- The Headers object
- The body is a stream
- The URL object
- fetch: making requests
- fetch does not throw on errors
- How Hectoday uses these primitives
- Testing with Request and Response
- Streaming responses
- The full picture
- Summary
A guide for developers who use fetch every day but have never thought about what's actually happening. Every example uses @hectoday/http, but the ideas are the web platform itself.
What HTTP actually is
HTTP is a conversation between two programs. One program (the client) sends a message asking for something. The other program (the server) sends a message back with an answer.
That's it. The request is the question. The response is the answer. Everything else is details about how the question is phrased and how the answer is formatted.
Client Server
│ │
│ "Give me /users" │
│ ──── Request ────────> │
│ │
│ "Here are the users" │
│ <──── Response ─────── │
│ │Every web page, every API call, every image load, every WebSocket handshake starts with this pattern.
A Request is just data
A request has four parts:
POST /users HTTP/1.1 ← method + path
Host: api.example.com ← headers
Content-Type: application/json
Authorization: Bearer token123
{"name": "Alice"} ← bodyMethod — what you want to do. GET means "give me data." POST means "here's new data." PUT means "replace this data." DELETE means "remove this data."
Path — which resource you're talking about. /users is the collection. /users/123 is a specific user.
Headers — metadata about the request. Who you are (Authorization), what format you're sending (Content-Type), what format you accept back (Accept).
Body — the payload. Not every request has one. GET requests typically don't. POST and PUT requests do.
The Request object
In JavaScript, the Request class represents all four parts:
const request = new Request("https://api.example.com/users", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer token123",
},
body: JSON.stringify({ name: "Alice" }),
});You can read everything from it:
request.method; // "POST"
request.url; // "https://api.example.com/users"
new URL(request.url).pathname; // "/users"
request.headers.get("authorization"); // "Bearer token123"
request.headers.get("content-type"); // "application/json"
await request.json(); // { name: "Alice" }This is a Web Standard. The same Request class works in browsers, Deno, Bun, Cloudflare Workers, and Node.js. It's not a framework abstraction. It's the platform.
A Response is also just data
A response has three parts:
HTTP/1.1 201 Created ← status
Content-Type: application/json ← headers
{"id": "1", "name": "Alice"} ← bodyStatus — a number that says what happened. 200 means success. 201 means created. 400 means you sent bad data. 404 means not found. 500 means the server broke.
Headers — metadata about the response. What format the body is in (Content-Type), how long to cache it (Cache-Control), a unique ID for the request (X-Request-Id).
Body — the payload. The data you asked for, or an error message explaining what went wrong.
The Response object
// Plain text
new Response("Hello World");
// JSON
Response.json({ name: "Alice" });
// JSON with status
Response.json({ name: "Alice" }, { status: 201 });
// Custom headers
new Response("OK", {
headers: { "x-custom": "value" },
});
// No body
new Response(null, { status: 204 });You can read everything from a response:
response.status; // 201
response.ok; // true (status 200-299)
response.headers.get("content-type"); // "application/json"
await response.json(); // { name: "Alice" }
await response.text(); // '{"name":"Alice"}'Again, this is a Web Standard. Same class everywhere.
Status codes are just numbers
They fall into five groups:
1xx — informational. Rare. You almost never see these.
2xx — success. The request worked.
200; // OK — here's what you asked for
201; // Created — I made the thing you asked me to make
204; // No Content — done, nothing to send back3xx — redirection. Look somewhere else.
301; // Moved Permanently — use this new URL from now on
302; // Found — temporarily look here instead
304; // Not Modified — you already have the latest version4xx — client error. You did something wrong.
400; // Bad Request — your data is malformed
401; // Unauthorized — who are you?
403; // Forbidden — I know who you are, but you can't do this
404; // Not Found — that thing doesn't exist
409; // Conflict — conflicts with current state
429; // Too Many Requests — slow down5xx — server error. The server did something wrong.
500; // Internal Server Error — something broke
502; // Bad Gateway — upstream server failed
503; // Service Unavailable — try again laterThe status code is the first thing the client reads. It tells the client whether to parse the body as data or as an error.
Headers you'll actually use
Request headers
// What format I'm sending
"content-type": "application/json"
// Who I am
"authorization": "Bearer eyJhbGciOiJI..."
// What format I want back
"accept": "application/json"
// Conditional request (caching)
"if-none-match": '"abc123"'
// Where I came from (CORS)
"origin": "https://myapp.com"Response headers
// What format the body is in
"content-type": "application/json"
// How to cache this response
"cache-control": "public, max-age=60"
// Fingerprint of the response (caching)
"etag": '"abc123"'
// Who's allowed to read this (CORS)
"access-control-allow-origin": "https://myapp.com"
// Custom metadata
"x-request-id": "req_abc123"Headers are case-insensitive. Content-Type, content-type, and CONTENT-TYPE are all the same header.
The Headers object
// Create from an object
const headers = new Headers({
"content-type": "application/json",
authorization: "Bearer token",
});
// Read
headers.get("content-type"); // "application/json"
headers.has("authorization"); // true
// Write
headers.set("x-custom", "value");
headers.append("set-cookie", "a=1");
headers.delete("authorization");
// Iterate
for (const [name, value] of headers) {
console.log(name, value);
}set replaces. append adds another value with the same name (useful for Set-Cookie).
The body is a stream
Request and response bodies are not strings. They're ReadableStream objects. This means:
// You can only read the body once
const body = await request.json();
const body2 = await request.json(); // ERROR: body already consumedThe body is consumed on first read. This is by design — streams are meant to be read once. It matters for Hectoday: if you define a body schema, the framework reads the body for validation. You can't call c.request.json() again in the handler.
Convenience methods for reading:
await request.json(); // parse as JSON → object
await request.text(); // read as string
await request.arrayBuffer(); // read as binary
await request.formData(); // parse as form data
await request.blob(); // read as BlobSame methods exist on Response.
The URL object
URLs have structure:
https://api.example.com:3000/users?page=2&limit=10#section
└─┬──┘ └──────┬───────┘└┬─┘└──┬─┘└────────┬───────┘└──┬──┘
scheme hostname port path search hashThe URL class parses them:
const url = new URL("https://api.example.com:3000/users?page=2&limit=10");
url.origin; // "https://api.example.com:3000"
url.hostname; // "api.example.com"
url.port; // "3000"
url.pathname; // "/users"
url.search; // "?page=2&limit=10"
url.searchParams.get("page"); // "2"
url.searchParams.get("limit"); // "10"In a Hectoday handler, you get the URL from the request:
resolve: (c) => {
const url = new URL(c.request.url);
// url.pathname, url.searchParams, etc.
};Though you rarely need to do this, because the framework parses params and query for you in c.input.
fetch: making requests
fetch is the function that sends a request and returns a response:
const response = await fetch("https://api.example.com/users");That's a GET request. For other methods:
const response = await fetch("https://api.example.com/users", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Alice" }),
});fetch returns a Promise<Response>. The promise resolves when the response headers arrive, not when the body is fully downloaded. This means:
const response = await fetch(url); // headers arrived
const data = await response.json(); // body downloaded and parsedTwo awaits. First for the headers (status, content-type). Second for the body (the actual data).
fetch does not throw on errors
This surprises everyone. fetch only throws on network failures (DNS resolution failed, connection refused, timeout). It does not throw on HTTP errors:
const response = await fetch("https://api.example.com/nonexistent");
// response.status is 404
// response.ok is false
// No error thrownYou have to check yourself:
const response = await fetch(url);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message);
}
const data = await response.json();response.ok is true when the status is 200-299. Everything else is false.
How Hectoday uses these primitives
A Hectoday server is a function that takes a Request and returns a Response:
const app = setup({
routes: [
route.get("/hello", {
resolve: () => new Response("Hello World"),
}),
],
});
// app.fetch is: (Request) => Response | Promise<Response>That's the entire interface. The framework receives a Web Standard Request, does routing and validation, calls your handler, and you return a Web Standard Response.
In a handler, you work with the same objects directly:
route.post("/users", {
request: {
body: z.object({ name: z.string() }),
},
resolve: async (c) => {
// c.request is the original Request object
const contentType = c.request.headers.get("content-type");
const method = c.request.method;
const url = new URL(c.request.url);
// c.input has the validated data from the request
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
// Return a Response
return Response.json({ id: "1", name: c.input.body.name }, { status: 201 });
},
});No wrappers. No res.send(). No ctx.body =. Just Request in, Response out.
Testing with Request and Response
Because the interface is Web Standards, testing is just constructing a Request and checking the Response:
// Build a request
const res = await app.fetch(
new Request("http://localhost/users", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Alice" }),
}),
);
// Check the response
expect(res.status).toBe(201);
const body = await res.json();
expect(body.name).toBe("Alice");Or use app.request which builds the Request for you:
const res = await app.request("/users", {
method: "POST",
body: { name: "Alice" },
});
expect(res.status).toBe(201);Same objects, same APIs. Nothing framework-specific to learn.
Streaming responses
A response body can be a ReadableStream. This lets you send data as it becomes available instead of waiting for everything to be ready:
route.get("/stream", {
resolve: () => {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("Hello "));
setTimeout(() => {
controller.enqueue(new TextEncoder().encode("World"));
controller.close();
}, 1000);
},
});
return new Response(stream, {
headers: { "content-type": "text/plain" },
});
},
});The client receives "Hello " immediately, then "World" one second later.
Server-Sent Events (SSE) use this pattern:
route.get("/events", {
resolve: () => {
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
let count = 0;
const interval = setInterval(() => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ count: ++count })}\n\n`));
if (count >= 10) {
clearInterval(interval);
controller.close();
}
}, 1000);
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
},
});
},
});No special framework support needed. SSE is just a streaming response with a specific content type and message format.
The full picture
Every HTTP interaction is the same shape:
// Client side
const request = new Request(url, { method, headers, body });
const response = await fetch(request);
const data = await response.json();
// Server side
function handle(request: Request): Response {
// read request.method, request.headers, request.url
// do work
// return new Response(body, { status, headers })
}Three classes: Request, Response, Headers. One function: fetch. One parsing utility: URL. That's the entire HTTP toolkit in JavaScript.
Everything Hectoday does is built on these five things. The framework adds routing (which URL goes to which handler), validation (checking the request data with Zod), and hooks (running code before/after). But the inputs and outputs are always Request and Response.
Learn these APIs and you understand every web framework. They all receive requests and return responses. The rest is convenience.
Summary
| Concept | What it is |
|---|---|
Request |
The question: method, URL, headers, body |
Response |
The answer: status, headers, body |
Headers |
Key-value metadata on requests and responses |
URL |
Parsed URL with pathname, search params, origin |
fetch |
Sends a Request, returns a Promise of Response |
| Status code | Number indicating success (2xx), client error (4xx), server error (5xx) |
| Body | A stream, read once, with convenience methods (.json(), .text()) |
ReadableStream |
For streaming responses like SSE |