This is the first entry in a weekly series where I turn backend
concepts into small experiments instead of stopping at definitions.
A request can look almost too simple:
const response = await fetch("https://api.example.com/products");
const products = await response.json();
Two lines of JavaScript. But those lines hide a surprising amount of
work: the browser applies its security rules, a hostname must be
resolved, a connection is established, HTTP metadata travels with the
request, a server chooses a route, application code runs, and a response
makes its way back.
When something goes wrong, the browser might show a CORS error, a 401,
a 304, or a response that seems to have disappeared. Those messages
can feel unrelated. They become easier to debug when we stop treating an
API call as a single action and start tracing the steps around it.
That is the goal of this week's lab: make the invisible parts of an
HTTP request observable.
I am learning these concepts as part of my backend-from-first-principles
study. The explanations below are written in my own words, and the
examples are designed to be reproducible. The most useful part is not
memorising every header; it is learning what to inspect when the
behaviour differs from what I expected.
1. Follow one request from the browser to the server
Imagine a frontend requesting:
GET /api/products/1 HTTP/1.1
Host: api.example.com
Accept: application/json
A typical request path might look like this:
- DNS lookup: the client resolves
api.example.comto an IP address. The answer may already be cached, so a DNS lookup is not necessarily performed for every request. - Connection and transport: the client establishes or reuses a connection. With HTTP/1.1 or HTTP/2, this commonly means TCP; HTTPS also involves TLS. HTTP/3 uses QUIC over UDP instead.
- Network edge: depending on the deployment, the request may pass through a firewall, load balancer, CDN, or reverse proxy such as Nginx.
- Application server: the request reaches the process listening for it. The application matches the method and path to a handler.
- Application work: the handler validates input, applies business rules, and may read from or write to a database or another service.
- Response: the server sends a status code, headers, and usually a body. The client then decides how to handle that response.
This is a mental model, not a fixed architecture. A small local Node
server may skip the proxy and cloud infrastructure entirely. A
production system may have several services between the browser and the
code that finally handles the request.
The practical debugging question is: which step did the request
reach? The browser's Network panel, server logs, and a command-line
client each reveal a different part of the story.
2. Why split frontend and backend?
Frontend code is delivered to a user's device and runs in an environment
the developer does not control. A user can inspect that code and modify
what happens locally. That makes the browser the wrong place to keep
secrets or enforce rules that must be trusted.
The backend is where a system can keep private credentials, enforce
permissions, validate important operations, and communicate with
databases using server-side connections. It can also perform work that
would be expensive or inappropriate for a user's device.
A useful rule of thumb:
- The frontend handles presentation and user interaction.
- The backend owns trusted application rules and mediates access to protected resources.
- The database stores and retrieves persistent data, but should not be exposed directly to an untrusted browser.
This is not because frontend code is incapable of doing calculations. It
is because calculations and decisions that must be trusted need to be
enforced somewhere the user cannot simply rewrite.
CORS is related to browser security, but it is not a replacement for
authentication or authorization. We'll test that distinction shortly.
3. Read the HTTP message, not just the JSON
HTTP gives the client and server a shared format for describing a
request and its response. A simplified request might be:
PUT /api/users/123 HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: Bearer <token>
{"name":"Akhil"}
It has a request line, headers, a blank line, and an optional body. The
response has a status line, headers, a blank line, and usually a body:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{"id":123,"name":"Akhil"}
The JSON is only the body. The status and headers tell the client how to
interpret it.
A few headers worth recognising:
-
Content-Typedescribes the representation being sent, such asapplication/json. -
Accepttells the server what response formats the client can accept. -
Authorizationcarries credentials or a token. -
Cache-Controldescribes caching rules. -
ETagidentifies a particular representation version for cache validation. -
Content-Encodingdescribes a transformation such as gzip compression.
Headers are not decoration. They are part of the protocol, and changing
one can change how a browser or server behaves.
4. HTTP methods: intent matters
The common methods express different kinds of operations:
Method Typical intent Idempotent?
GET Read a representation Yes
POST Submit data or create a Not generally
resource
PUT Create or replace the Yes
target resource with
the supplied
representation
PATCH Apply a partial Depends on the patch
modification operation
DELETE Remove the target Yes
resource
Idempotent does not mean "returns the same response every time." It
means that repeating the same request has the same intended effect on
server state as making it once.
For example, setting a product's price to 600 repeatedly can leave the
product at 600. Creating an order repeatedly can create a new order
each time. A repeated DELETE can return a different status on the
second call while still being idempotent if the resource remains
deleted.
This distinction matters when designing retry behaviour. A network can
fail after a server has processed a request but before the client
receives the response. The client may not know whether the operation
happened. Blindly retrying a non-idempotent operation can create
duplicates; real payment or order systems often need explicit
idempotency keys and server-side handling.
5. CORS: the browser may hide a response the server sent
Browsers apply the same-origin policy. An origin is the combination of
scheme, host, and port, so http://localhost:5173 and
http://localhost:3001 are different origins even though both use
localhost.
Cross-Origin Resource Sharing (CORS) is a mechanism through which a
server tells a browser which origins may read a response. For a request
that does not require preflight, the browser can send the request and
then check the response's CORS headers before exposing the response to
JavaScript.
That creates a useful distinction:
- The server may receive a request and send a response.
- The browser may still refuse to expose that response to the page's JavaScript.
Some requests trigger a preflight: the browser first sends an
OPTIONS request asking whether the origin, method, and requested
headers are allowed. A JSON PUT or a request with an Authorization
header commonly triggers this check.
A simplified preflight looks like this:
OPTIONS /api/products/1 HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type
If the server approves it, the browser can proceed with the actual
request. If the preflight fails, the browser normally does not send that
actual request.
CORS is not authentication. A command-line tool such as curl does
not enforce browser CORS rules. A server must still authenticate callers
and check their permissions; CORS alone does not protect an API from
direct requests.
6. Status codes are clues, not complete explanations
The status code gives the client a broad category of outcome:
- 2xx --- success:
200 OK,201 Created,204 No Content. - 3xx --- redirection or cache validation:
301,302,304 Not Modified. - 4xx --- request or client-side problem:
400 Bad Request,401 Unauthorized,403 Forbidden,404 Not Found,409 Conflict,429 Too Many Requests. - 5xx --- server-side failure or temporary unavailability:
500 Internal Server Error,503 Service Unavailable.
Two common confusions are worth clearing up:
-
401means valid authentication credentials are missing or unacceptable;403means the server refuses the action. In real applications, exact behaviour depends on the authentication scheme and API design. - A
404does not prove that a server is down. It often means the server is reachable but the requested route or resource was not found.
A status code is one clue. Combine it with the response body, request
headers, server logs, and the route that handled the request.
7. Caching: sometimes the best request is no request
Caching allows a client to reuse a response rather than download the
same data repeatedly. For example:
Cache-Control: max-age=10, public
ETag: "abc123"
max-age=10 says that the response can be considered fresh for ten
seconds under the applicable caching rules. While a response is fresh,
the browser may reuse it without contacting the server.
After it becomes stale, the client can revalidate it:
If-None-Match: "abc123"
If the representation has not changed, the server can reply with
304 Not Modified. That response has no representation body; the client
reuses the body it already has. If the representation has changed, the
server sends a fresh response, commonly with 200 OK and a new ETag.
There are two behaviours to distinguish when debugging:
- Fresh-cache reuse: the browser may make no network request at all.
- Revalidation: the browser contacts the server, which can reply
304or return a new representation.
Browser APIs such as fetch can make cache behaviour less obvious than
it looks in the Network panel. The lab below logs requests on the server
so we can compare what JavaScript receives with what actually reached
the server.
8. Representation, compression, uploads, and streaming
HTTP can carry more than one kind of payload. The headers help both
sides agree on how to interpret it.
- Content negotiation:
Acceptand related headers express preferences about response formats, languages, or encodings. The server may choose among supported representations. - Compression:
Accept-Encodingtells the server which content encodings the client supports. If the server compresses a suitable response, it identifies the encoding withContent-Encoding. Compression can reduce transferred bytes, but the result depends on the data, algorithm, and configuration. - Multipart uploads:
multipart/form-datapackages form fields and file content into parts separated by a boundary. It is common for browser file uploads. - Streaming: instead of waiting for the entire response body, a
client can consume chunks as they arrive. Server-Sent Events (SSE)
use
text/event-streamfor one-way updates from server to client.
Streaming is especially useful when waiting for the complete result
would make an interface feel slow. The client can begin displaying
progress or partial output while the server continues working. It is not
the same as simply returning a large JSON object: with ordinary JSON,
the client generally needs the complete body before parsing it as one
JSON value.
9. Routing and API versioning
A route maps an HTTP method and a path to a handler. The path can
identify a collection or a particular resource, while query parameters
often control filtering or pagination.
GET /api/books
GET /api/users/123
GET /api/search?q=backend&page=2&limit=10
GET /api/users/7/products/1
The path and query string serve different purposes. /api/users/123
identifies a user; ?page=2&limit=10 commonly describes how to retrieve
a slice of a collection.
APIs also evolve. If clients depend on a response containing name and
price, changing those fields in place can break them. One option is to
introduce a new version:
-
/api/v1/products/1returnsid,name, andprice. -
/api/v2/products/1returnsid,title,cost, andcurrency.
Versioning is not free: it adds maintenance work and old versions need a
deprecation plan. The point is to make incompatible changes deliberately
rather than accidentally breaking every existing client.
The experiment: make the request visible
Definitions are useful, but they do not always predict what the browser
will show. For the rest of this post, I use a small local playground to
test a few behaviours that are easy to misunderstand:
- A request can reach the server even when browser JavaScript cannot read the response.
- A preflight can fail before the actual request is sent.
- A cache hit and a
304revalidation are different events. - Repeating an idempotent request does not necessarily produce an identical response.
- Compression and streaming are visible in the bytes transferred and the timing of chunks.
The lab is deliberately small and uses Node's built-in modules. It is
not a production server; it is a controlled environment for learning how
HTTP behaves.
The server below is intentionally compact: it includes a small router,
an in-memory product, example status endpoints, cache validation, gzip
compression, an SSE endpoint, and a request log. The data is reset
whenever the process restarts.
One important limitation: the /api/status/:code route is a
demonstration tool that returns the requested status code. Clicking
403 or 409 there does not reproduce every real-world permission or
conflict scenario; it lets you inspect how a client displays different
status codes. The /api/secure route is also only a toy example and
does not validate a real token.
server.js
// backend-lab/server.js
// Zero-dependency starter for the "Backend from First Principles" series.
//
// node server.js
// API -> http://localhost:3001
// Page -> http://localhost:5173 (a different origin on purpose, so CORS is real)
const http = require('http');
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const crypto = require('crypto');
const API_PORT = Number(process.env.API_PORT) || 3001;
const WEB_PORT = Number(process.env.WEB_PORT) || 5173;
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || `http://localhost:${WEB_PORT}`;
// ---------- in-memory "database" ----------
let product = { id: 1, name: 'iPhone', price: 500 };
const orders = [];
const catalog = Array.from({ length: 25 }, (_, i) => ({
id: i + 1,
name: `Item ${i + 1}`,
price: 100 + i * 10,
}));
const requestLog = []; // what the server really saw (browsers hide some of it)
// ---------- helpers ----------
const etagOf = (s) =>
'"' + crypto.createHash('sha1').update(s).digest('hex').slice(0, 8) + '"';
// Send JSON. Compresses with gzip when the client allows it and the body is big.
function send(req, res, status, data, headers = {}) {
const raw = typeof data === 'string' ? data : JSON.stringify(data);
let body = Buffer.from(raw);
const h = { 'Content-Type': 'application/json', ...headers };
if (body.length > 1024 && /\bgzip\b/.test(req.headers['accept-encoding'] || '')) {
body = zlib.gzipSync(body);
h['Content-Encoding'] = 'gzip';
}
res.rawBytes = Buffer.byteLength(raw);
res.sentBytes = body.length;
res.writeHead(status, h);
res.end(body);
}
function readJson(req) {
return new Promise((resolve, reject) => {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
if (!raw) return resolve({});
try {
resolve(JSON.parse(raw));
} catch {
reject(Object.assign(new Error('Invalid JSON'), { status: 400 }));
}
});
});
}
// ---------- tiny router: method + pattern -> handler ----------
const routes = [];
function route(method, pattern, handler) {
const keys = [];
const regex = new RegExp(
'^' + pattern.replace(/:(\w+)/g, (_, k) => (keys.push(k), '([^/]+)')) + '$'
);
routes.push({ method, regex, keys, handler });
}
// Static, path params, query params, nested resources
route('GET', '/api/v1/products', ({ req, res, query }) => {
const page = Math.max(1, Number(query.get('page')) || 1);
const limit = Math.min(50, Math.max(1, Number(query.get('limit')) || 10));
send(req, res, 200, {
page,
limit,
total: catalog.length,
items: catalog.slice((page - 1) * limit, page * limit),
});
});
// v1 supports HTTP caching (ETag + Cache-Control)
route('GET', '/api/v1/products/:id', ({ req, res, params }) => {
if (Number(params.id) !== 1) return send(req, res, 404, { error: 'Product not found' });
const body = JSON.stringify(product);
const etag = etagOf(body);
if (req.headers['if-none-match'] === etag) {
res.writeHead(304, { ETag: etag });
return res.end();
}
send(req, res, 200, body, { 'Cache-Control': 'max-age=10, public', ETag: etag });
});
// v2: same resource, new shape. Old clients keep using v1.
route('GET', '/api/v2/products/:id', ({ req, res, params }) => {
if (Number(params.id) !== 1) return send(req, res, 404, { error: 'Product not found' });
send(req, res, 200, {
id: product.id,
title: product.name,
cost: product.price,
currency: 'USD',
});
});
// PUT replaces the whole resource (idempotent)
route('PUT', '/api/v1/products/:id', ({ req, res, body }) => {
if (typeof body.name !== 'string' || typeof body.price !== 'number') {
return send(req, res, 400, { error: 'name (string) and price (number) are required' });
}
product = { id: 1, name: body.name, price: body.price };
send(req, res, 200, product);
});
// POST creates something new every time (not idempotent)
route('POST', '/api/orders', ({ req, res }) => {
orders.push({ id: orders.length + 1 });
send(req, res, 201, { totalOrders: orders.length });
});
route('GET', '/api/users/:userId/products/:productId', ({ req, res, params }) =>
send(req, res, 200, params)
);
// Return any status code on demand
route('GET', '/api/status/:code', ({ req, res, params }) => {
const code = Number(params.code);
if (!(code >= 200 && code <= 599)) return send(req, res, 400, { error: 'Use 200-599' });
if (code === 204 || code === 304) {
res.writeHead(code);
return res.end();
}
send(req, res, code, { status: code });
});
// 401 without an Authorization header, 200 with one
route('GET', '/api/secure', ({ req, res }) => {
if (!req.headers.authorization) {
return send(req, res, 401, { error: 'Missing Authorization header' }, {
'WWW-Authenticate': 'Bearer',
});
}
send(req, res, 200, { message: 'You are in' });
});
// A large, very compressible response
route('GET', '/api/big', ({ req, res }) =>
send(req, res, 200, {
items: Array.from({ length: 500 }, (_, i) => ({
id: i,
name: `Item ${i}`,
description: 'lorem ipsum dolor sit amet '.repeat(3),
})),
})
);
// Streaming with Server-Sent Events: five events, 300 ms apart
route('GET', '/api/stream', ({ res }) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
let i = 0;
const timer = setInterval(() => {
res.write(`data: token-${++i}\n\n`);
if (i === 5) {
clearInterval(timer);
res.end();
}
}, 300);
});
// What the server really saw (the playground shows this). Readable from any origin
// on purpose, so you can still inspect the server side when your CORS setup is broken.
route('GET', '/api/_log', ({ req, res }) =>
send(req, res, 200, [...requestLog].reverse().slice(0, 15), { 'Access-Control-Allow-Origin': '*' })
);
// ---------- the API server ----------
http
.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const origin = req.headers.origin;
res.on('finish', () => {
if (url.pathname === '/api/_log') return;
requestLog.push({
time: new Date().toISOString().slice(11, 23),
method: req.method,
path: url.pathname + url.search,
status: res.statusCode,
origin: origin || null,
rawBytes: res.rawBytes,
sentBytes: res.sentBytes,
});
if (requestLog.length > 50) requestLog.shift();
console.log(`${req.method} ${url.pathname}${url.search} -> ${res.statusCode}`);
});
// CORS: the server only opts an origin in. The browser enforces it.
res.setHeader('Vary', 'Origin');
if (origin === ALLOWED_ORIGIN) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Expose-Headers', 'ETag, Content-Encoding, Cache-Control');
}
// Preflight
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE',
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
'Access-Control-Max-Age': '86400',
});
return res.end();
}
const match = routes.find((r) => r.method === req.method && r.regex.test(url.pathname));
if (!match) return send(req, res, 404, { error: 'Not found' });
const m = url.pathname.match(match.regex);
const params = Object.fromEntries(match.keys.map((k, i) => [k, decodeURIComponent(m[i + 1])]));
try {
const body = ['POST', 'PUT', 'PATCH'].includes(req.method) ? await readJson(req) : undefined;
match.handler({ req, res, params, query: url.searchParams, body });
} catch (err) {
send(req, res, err.status || 500, { error: err.message });
}
})
.listen(API_PORT, () => console.log(`API http://localhost:${API_PORT} (CORS allows ${ALLOWED_ORIGIN})`));
// ---------- a second server for the page, so it is a different origin ----------
const PUBLIC = path.join(__dirname, 'public');
const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css' };
http
.createServer((req, res) => {
const file = req.url.split('?')[0] === '/' ? '/index.html' : req.url.split('?')[0];
const full = path.join(PUBLIC, path.normalize(file));
if (!full.startsWith(PUBLIC)) {
res.writeHead(403);
return res.end();
}
fs.readFile(full, (err, data) => {
if (err) {
res.writeHead(404);
return res.end('Not found');
}
res.writeHead(200, { 'Content-Type': mime[path.extname(full)] || 'application/octet-stream' });
res.end(data);
});
})
.listen(WEB_PORT, () => console.log(`Page http://localhost:${WEB_PORT} <- open this`));
It is about 250 lines: a tiny router (method plus pattern maps to a
handler), an in-memory "database", and one handler per concept from the
notes above. Two things to notice. CORS is just a few lines at the top
of the main handler. And every request is also written to a log the
playground reads back, which is how you will see what the server saw,
not only what the browser showed you.
public/index.html
This page is the interactive part. Each card has buttons that fire real
requests at the API. The panel on the right shows what the browser
handed to your code, and the table under it shows what the server
actually saw.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Backend Lab</title>
<style>
:root { --bg:#fafaf8; --card:#fff; --ink:#1c1c1a; --mute:#6b6b66; --line:#e4e3de; --ok:#1a7f45; --bad:#b3261e; --accent:#2f5bea; }
@media (prefers-color-scheme: dark) {
:root { --bg:#16161a; --card:#1f1f25; --ink:#ececea; --mute:#9a9a94; --line:#33333b; --ok:#4cc37f; --bad:#ff7b72; --accent:#7c9cff; }
}
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 system-ui, sans-serif; }
header { padding:20px 16px 8px; max-width:1100px; margin:0 auto; }
h1 { margin:0 0 4px; font-size:22px; }
header p { margin:0; color:var(--mute); }
main { max-width:1100px; margin:0 auto; padding:8px 16px 40px; display:grid; gap:16px; grid-template-columns:minmax(0,5fr) minmax(0,6fr); }
@media (max-width:820px) { main { grid-template-columns:1fr; } }
section { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:12px 14px; margin-bottom:12px; }
h2 { margin:0 0 2px; font-size:15px; }
.note { margin:0 0 8px; color:var(--mute); font-size:13px; }
button { font:inherit; font-size:13px; margin:0 6px 6px 0; padding:5px 10px; border:1px solid var(--line); border-radius:6px; background:var(--bg); color:var(--ink); cursor:pointer; }
button:hover { border-color:var(--accent); color:var(--accent); }
.panel-head { display:flex; justify-content:space-between; align-items:center; }
.panel-head h2 { margin:0; }
#out .entry { border-top:1px solid var(--line); padding:8px 0; }
#out .entry:first-child { border-top:0; }
.ok { color:var(--ok); } .bad { color:var(--bad); } .ms { color:var(--mute); font-size:12px; }
pre { margin:6px 0 0; padding:8px; background:var(--bg); border-radius:6px; font:12px/1.45 ui-monospace, Menlo, monospace; white-space:pre-wrap; word-break:break-word; }
table { width:100%; border-collapse:collapse; font:12px ui-monospace, Menlo, monospace; }
td, th { text-align:left; padding:3px 6px; border-bottom:1px solid var(--line); }
th { color:var(--mute); font-weight:500; }
</style>
</head>
<body>
<header>
<h1>Backend Lab</h1>
<p>This page runs on <b id="me"></b> and calls an API on <b>http://localhost:3001</b>. Different origins, so CORS is real.</p>
</header>
<main>
<div id="controls"></div>
<div>
<section>
<div class="panel-head"><h2>What the browser showed you</h2><button id="clear">Clear</button></div>
<div id="out"><p class="note">Click a button on the left.</p></div>
</section>
<section>
<h2>What the server really saw</h2>
<p class="note">Browsers hide some things (304s, preflights). The server does not.</p>
<table><thead><tr><th>time</th><th>request</th><th>status</th><th>bytes</th></tr></thead><tbody id="log"></tbody></table>
</section>
</div>
</main>
<script>
const API = 'http://localhost:3001';
const out = document.getElementById('out');
document.getElementById('me').textContent = location.origin;
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
const json = (method, body) => ({ method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
function addEntry(html) {
if (out.querySelector('.note')) out.innerHTML = '';
const el = document.createElement('div');
el.className = 'entry';
el.innerHTML = html;
out.prepend(el);
return el;
}
async function call(label, path, opts = {}) {
const t0 = performance.now();
try {
const res = await fetch(API + path, opts);
const text = await res.text();
const ms = Math.round(performance.now() - t0);
const hdrs = ['etag', 'cache-control', 'content-encoding']
.filter((h) => res.headers.get(h))
.map((h) => `${h}: ${res.headers.get(h)}`)
.join('\n');
addEntry(`<b class="${res.ok ? 'ok' : 'bad'}">${res.status}</b> ${esc(label)} <span class="ms">${ms} ms</span>` +
`<pre>${esc((hdrs ? hdrs + '\n\n' : '') + (text.slice(0, 300) || '(empty body)'))}</pre>`);
} catch {
addEntry(`<b class="bad">blocked</b> ${esc(label)}<pre>The browser refused to hand this response to your code, usually because of CORS. ` +
`Open DevTools > Console for the exact reason, then check "What the server really saw". A simple request still reaches the server; a preflighted one stops at the OPTIONS.</pre>`);
}
refreshLog();
}
async function stream() {
const t0 = performance.now();
const el = addEntry(`<b class="ok">stream</b> GET /api/stream<pre></pre>`);
const pre = el.querySelector('pre');
const res = await fetch(API + '/api/stream');
const reader = res.body.getReader();
const dec = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
pre.textContent += `+${Math.round(performance.now() - t0)} ms ${dec.decode(value).trim()}\n`;
}
refreshLog();
}
async function refreshLog() {
try {
const rows = await (await fetch(API + '/api/_log', { cache: 'no-store' })).json();
document.getElementById('log').innerHTML = rows.map((r) => {
const bytes = r.rawBytes == null ? '' : r.rawBytes === r.sentBytes ? r.sentBytes : `${r.rawBytes} → ${r.sentBytes}`;
return `<tr><td>${r.time}</td><td>${r.method} ${esc(r.path)}</td><td>${r.status}</td><td>${bytes}</td></tr>`;
}).join('');
} catch { /* server not reachable */ }
}
const groups = [
{ title: '1 · Routing and versioning', note: 'Static, path params, query params, nested, and v1 vs v2 of one resource.', items: [
['GET /api/v1/products/1', '/api/v1/products/1'],
['GET /api/v2/products/1', '/api/v2/products/1'],
['GET /api/v1/products?page=2&limit=5', '/api/v1/products?page=2&limit=5'],
['GET /api/users/7/products/1', '/api/users/7/products/1'],
]},
{ title: '2 · Status codes', note: 'The server returns whatever code you click.', items:
[200, 400, 401, 403, 404, 409, 429, 500, 503].map((c) => [`${c}`, `/api/status/${c}`]) },
{ title: '3 · CORS and preflight', note: 'Open DevTools > Network. The last two send an OPTIONS request first.', items: [
['Simple GET (no preflight)', '/api/v1/products/1'],
['GET without Authorization (401)', '/api/secure'],
['GET + Authorization (preflight)', '/api/secure', { headers: { Authorization: 'Bearer demo' } }],
['PUT with JSON body (preflight)', '/api/v1/products/1', json('PUT', { name: 'iPhone', price: 600 })],
]},
{ title: '4 · Caching', note: 'fetch hides 304s. Watch the server table for the real status.', items: [
['GET product (ETag + max-age=10)', '/api/v1/products/1'],
['GET, force revalidation', '/api/v1/products/1', { cache: 'no-cache' }],
['PUT price to 700', '/api/v1/products/1', json('PUT', { name: 'iPhone', price: 700 })],
['PUT price back to 500', '/api/v1/products/1', json('PUT', { name: 'iPhone', price: 500 })],
]},
{ title: '5 · Idempotency', note: 'Click each one three times and compare.', items: [
['PUT price 600 (idempotent)', '/api/v1/products/1', json('PUT', { name: 'iPhone', price: 600 })],
['POST /api/orders (not idempotent)', '/api/orders', { method: 'POST' }],
]},
{ title: '6 · Compression', note: 'Check the bytes column: raw size → size on the wire.', items: [
['GET /api/big', '/api/big'],
]},
];
const controls = document.getElementById('controls');
for (const g of groups) {
const s = document.createElement('section');
s.innerHTML = `<h2>${g.title}</h2><p class="note">${g.note}</p>`;
for (const [label, path, opts] of g.items) {
const b = document.createElement('button');
b.textContent = label;
b.onclick = () => call(`${opts?.method || 'GET'} ${path}`, path, opts);
s.appendChild(b);
}
controls.appendChild(s);
}
const s7 = document.createElement('section');
s7.innerHTML = '<h2>7 · Streaming</h2><p class="note">One open connection, five events, no Content-Length.</p>';
const sb = document.createElement('button');
sb.textContent = 'GET /api/stream';
sb.onclick = stream;
s7.appendChild(sb);
controls.appendChild(s7);
document.getElementById('clear').onclick = () => (out.innerHTML = '<p class="note">Click a button on the left.</p>');
refreshLog();
</script>
</body>
</html>
That's the whole setup. Start the server, open the page, and you have a
lab bench for everything below.
Run the experiments
Start the lab with Node.js 18 or newer:
node server.js
Open http://localhost:5173, then open your browser's Developer Tools
and select Network. The page runs on port 5173; the API runs on
port 3001. Different ports mean different origins, so the browser's
CORS behaviour is part of the experiment.
For each test, make a prediction before clicking the button. Then
compare three things:
- The browser panel: what
fetchreturned to JavaScript. - The server log: which requests actually reached the API and which status codes it returned.
- The Network panel: request headers, response headers, preflight requests, transferred bytes, and timing.
That prediction-and-check loop is the point of the lab. Don't just
confirm that a request works; try to explain why it behaved that way.
Experiment 1 --- A CORS error does not always mean the request never arrived
Click the simple
GETrequest.In the server log, confirm that the request reached the API.
-
Restart the server with an origin that does not match the page's
origin:
ALLOWED_ORIGIN=http://example.com node server.js Reload the page and click the simple
GETagain.
The browser should block the page's JavaScript from reading the response
because the server did not allow the page's origin. The server log can
still show a 200 response. This is the first key distinction: a
browser CORS error describes what the browser permits the page to read;
it does not necessarily describe whether the server received a
request.
Now click the PUT button with a JSON body. Because this request
requires preflight, the browser first sends OPTIONS. With the origin
misconfigured, the preflight fails and the browser should not send the
actual PUT. Check the log rather than guessing from the error message.
Restore the default configuration before continuing.
Experiment 2 --- Inspect the preflight
Click the button that sends a PUT with JSON, then inspect the Network
panel. Look for an OPTIONS request before the PUT.
A preflight response can include headers such as:
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
These headers describe which origin, methods, and request headers the
browser is permitted to use. Access-Control-Max-Age lets the browser
cache the preflight result for the stated period, subject to browser
limits and the rest of the CORS configuration.
Try the GET with an Authorization header too. Then compare it with
the simple GET. The important question is not merely "Did the request
succeed?" but "Which requests did the browser send before the API
handler ran?"
Experiment 3 --- Watch caching change the request
- Click GET product.
- Click GET, force revalidation.
- Compare the browser panel with the server log.
- Change the product price with
PUT, then revalidate again.
The product endpoint returns an ETag and a Cache-Control header. The
first request should return a full 200 response. When you force
revalidation while the representation is unchanged, the server can
answer 304 Not Modified; the browser can then reuse the body it
already has. After the product changes, the ETag should change and the
server should send the updated representation.
To inspect the conditional request outside the browser, first make a
request and copy the actual ETag value from its response:
curl -i http://localhost:3001/api/v1/products/1
Then use that exact value:
curl -i http://localhost:3001/api/v1/products/1 \
-H 'If-None-Match: "PASTE_THE_ETAG_HERE"'
Do not assume the ETag is a permanent constant. It is derived from the
current representation, so changing the product changes the tag.
One more detail: a fresh cached response can be reused without a request
reaching the server at all. A 304 means a request did reach the
server and the server told the client to reuse its stored
representation. Those are different cache paths.
Experiment 4 --- Idempotency is about state
Click PUT price 600 several times. Then click POST /api/orders
several times.
The PUT replaces the product with the same supplied representation, so
repeating it leaves the product in the same state. The POST handler
adds another order each time, so the order count grows.
The distinction is about the effect on server state, not whether the
response bytes or status code are identical. For an extra challenge,
implement DELETE /api/v1/products/1 so the first request removes the
product and returns 204. Decide what the second request should return.
It could return 404, for example, while the resource remains deleted;
that can still be idempotent because the final state is unchanged.
Experiment 5 --- Measure compression and observe streaming
Compression: click GET /api/big and compare the raw and
transferred byte counts in the server log. The endpoint returns
repetitive JSON, which is a good candidate for gzip compression. If you
measure it with curl, use the same URL and compare the response with
and without an Accept-Encoding: gzip header:
curl -s http://localhost:3001/api/big | wc -c
curl -s -H 'Accept-Encoding: gzip' http://localhost:3001/api/big | wc -c
Exact byte counts can vary if the payload or code changes. Also,
command-line clients and browsers may display compressed responses
differently, so make sure you understand whether the tool is showing
compressed transfer bytes or the decompressed body.
Streaming: click GET /api/stream. The page should display five
events arriving over one connection, roughly 300 milliseconds apart.
Instead of waiting for one complete JSON body, the client reads chunks
as they arrive. Timing will vary across machines and browsers; focus on
the sequence of events and the fact that the connection remains open
while the server sends them.
What these experiments changed in my mental model
- A browser error is not always a server error. The browser, network, and server are separate places where a request can fail or be blocked.
- CORS is a browser-enforced sharing policy, not an API authentication system. Direct clients do not automatically follow browser CORS rules.
- The browser's view is not the whole story. Server logs reveal
requests and status codes that may not be obvious from the
fetchresult alone. - Caching can save a network request entirely. A fresh cache entry
and a conditional
304response are different paths. - Idempotency describes the intended state change. It is especially important when a client retries after an uncertain network failure.
- Headers affect behaviour. They communicate cache rules, representation formats, compression, and browser permissions.
- Measurements need context. Compression ratios and timings are observations from a particular payload and environment, not universal performance guarantees.
The biggest lesson for me is that backend debugging gets easier when I
stop looking only at the final JSON. The request method, headers,
status, timing, cache behaviour, and server logs all help explain what
happened.
Try this yourself
Before moving on, pick one of these small extensions:
- Add a
DELETEendpoint and test repeated requests. - Change the allowed origin and predict which requests will reach the API.
- Change the product twice and observe how its ETag changes.
- Add a query parameter for filtering the product list.
- Modify the streaming endpoint to send ten events instead of five.
For each change, write down what you expect to happen before testing it.
That habit is more valuable than memorising a list of HTTP headers.
Top comments (0)