An HTTP request is a text message made of a method, a target, headers and an optional body; a response is a status code, headers and a body. JSON is the format the body usually carries: objects, arrays, double-quoted strings, numbers, true, false and null. To work with APIs confidently you need three things: what a method means (is it safe, can it be retried), what the status code class says, and which Content-Type describes the body.
After this you will be able to
- Break an HTTP request and response down into method, target, headers, body and status code
- Explain which methods are safe and idempotent under RFC 9110 and why a POST must not be retried blindly
- Send GET and POST with JSON via curl and fetch and check the response status, not just the fact that a response arrived
- Find the cause of a JSON.parse error and predict what JSON.stringify drops during serialization
Know beforehand
Verified with
Node.js 24 LTS (24.18)curl 8.21HTTP/1.1JSON (RFC 8259)
Checked against docs: October 11, 2026
Contents
What a request and a response are made of
In the lesson on how a website works, a request was the “waiter” between the browser and the server. Here we look inside: what exactly the waiter carries. HTTP (HyperText Transfer Protocol) is a message exchange protocol: the client sends a request and the server returns a response. The client can be a browser, a mobile app, another server or curl in a terminal.
In HTTP/1.1 a request is plain text. The first line holds the method, the request target and the protocol version. Then come headers — “name: value” pairs, one per line. Then an empty line, and after it an optional body. This is a request that creates an order:
POST /api/orders HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/json
Content-Length: 27
{"item": "latte", "qty": 1}A response has the same shape with a different first line: protocol version, a three-digit status code and a short reason phrase, such as HTTP/1.1 201 Created. The phrase is informational only — programs rely on the number.
Methods: safety and idempotency
The method tells the server what the client wants. RFC 9110 defines two properties that drive the behaviour of browsers, proxies and your own code. A safe method is essentially read-only and does not ask the server to change anything. An idempotent method leaves the server in the same state no matter how many times an identical request is repeated.
GET
- Purpose
- Retrieve a representation of a resource
- Safe
- Yes
- Idempotent
- Yes
HEAD
- Purpose
- Same as GET, headers only
- Safe
- Yes
- Idempotent
- Yes
POST
- Purpose
- Submit data for processing, create a record
- Safe
- No
- Idempotent
- No
PUT
- Purpose
- Create or fully replace the resource at a URL
- Safe
- No
- Idempotent
- Yes
PATCH
- Purpose
- Partially modify a resource (RFC 5789)
- Safe
- No
- Idempotent
- No
DELETE
- Purpose
- Delete a resource
- Safe
- No
- Idempotent
- Yes
Idempotency is a practical property, not theory. RFC 9110 spells it out: if the connection drops before the response arrives, the client may retry a PUT or DELETE because repeating it will not change the outcome. A POST is different: a retry creates a second order or charges the card twice. That is why payment APIs add their own duplicate protection to POST, and forms block double submission.
Status codes and headers worth knowing
A status code is a three-digit number from 100 to 599. The first digit defines the class, and RFC 9110 requires a client to understand at least the class: an unknown 471 must be treated as a 400. The rule for your own code follows: check the class first, then specific values.
2xx
- Meaning
- Success: received, understood and accepted
- Where you see it
- 200 OK — data in the body; 201 Created — record created, URL in Location; 204 No Content — success without a body
3xx
- Meaning
- Further action needed
- Where you see it
- 301, 302, 308 — the resource lives at another URL
400
- Meaning
- The server cannot process the request due to a client error
- Where you see it
- The body is not valid JSON
401
- Meaning
- No valid credentials
- Where you see it
- Token missing or expired
404
- Meaning
- Resource not found
- Where you see it
- No order with that id
415
- Meaning
- Body format not supported
- Where you see it
- JSON sent without
Content-Type: application/json
422
- Meaning
- Syntax is fine, but the data breaks the rules
- Where you see it
- Quantity is zero
5xx
- Meaning
- The server failed to fulfil a valid request
- Where you see it
- 500 — an unhandled error in server code
Headers describe the message, not the data inside it. Header names are case-insensitive: Content-Type and content-type are the same. Node.js lowercases all names in req.headers, so server code reads req.headers["content-type"].
Content-Type— the body format:application/json,text/html; charset=utf-8. Without it, RFC 9110 lets the recipient treat the body as raw bytes or sniff the type — do not rely on that.Content-Length— body size in bytes, not characters: a Cyrillic letter takes two bytes in UTF-8.Authorization— credentials as “scheme value”, for exampleBearer YOUR_TOKEN. Anyone holding such a token can use it, so send it over HTTPS only.Cache-Control— caching rules.no-storeforbids storing the response,no-cacheallows storing but requires revalidation before every reuse,max-age=Nkeeps the response fresh for N seconds.Location— the URL of the created resource in a 201 response, or the new URL in a redirect.
Hands-on: your own API on node:http, requests via curl and fetch
The best way to see HTTP is to run a tiny server and send it requests. All you need is Node.js 24: the node:http module is built in, no external packages. The server keeps orders in memory, returns an order on GET and creates one on POST, checking the header, the JSON syntax and the data itself — each check maps to its own status code.
import { createServer } from "node:http";
const orders = new Map([[1, { id: 1, item: "cappuccino", qty: 2 }]]);
let nextId = 2;
function sendJson(res, status, data, headers = {}) {
const body = JSON.stringify(data);
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
...headers,
});
res.end(body);
}
async function readBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks).toString("utf8");
}
const server = createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
console.log(req.method, url.pathname, req.headers["content-type"] ?? "-");
const match = url.pathname.match(/^\/api\/orders\/(\d+)$/);
if (req.method === "GET" && match) {
const order = orders.get(Number(match[1]));
if (!order) return sendJson(res, 404, { error: "Order not found" });
return sendJson(res, 200, order, { "Cache-Control": "no-store" });
}
if (req.method === "POST" && url.pathname === "/api/orders") {
if (!req.headers["content-type"]?.startsWith("application/json")) {
return sendJson(res, 415, { error: "Expected Content-Type: application/json" });
}
let input;
try {
input = JSON.parse(await readBody(req));
} catch {
return sendJson(res, 400, { error: "Request body is not valid JSON" });
}
if (typeof input?.item !== "string" || !Number.isInteger(input.qty) || input.qty < 1) {
return sendJson(res, 422, { error: "Fields required: item (string), qty (integer >= 1)" });
}
const order = { id: nextId++, item: input.item, qty: input.qty };
orders.set(order.id, order);
return sendJson(res, 201, order, { Location: `/api/orders/${order.id}` });
}
sendJson(res, 404, { error: "Not found" });
});
server.listen(8080, "127.0.0.1", () => {
console.log("API: http://127.0.0.1:8080");
});Run node server.mjs and open a second terminal. The curl flag -s hides the progress meter, -i prints the status line and response headers, -X sets the method, -H adds a header, -d sends a body. The commands are for bash (macOS, Linux, Git Bash on Windows): PowerShell quotes differently. The output is real, from our run:
$ curl -si http://127.0.0.1:8080/api/orders/1
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 36
Cache-Control: no-store
Date: Sun, 11 Oct 2026 21:56:00 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"item":"cappuccino","qty":2}
$ curl -si -X POST http://127.0.0.1:8080/api/orders -H 'Content-Type: application/json' -d '{"item": "latte", "qty": 1}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Content-Length: 31
Location: /api/orders/2
Date: Sun, 11 Oct 2026 21:56:00 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":2,"item":"latte","qty":1}
$ curl -si -X POST http://127.0.0.1:8080/api/orders -H 'Content-Type: application/json' -d "{'item': 'latte'}"
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Content-Length: 42
Date: Sun, 11 Oct 2026 21:56:00 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"Request body is not valid JSON"}
$ curl -si -X POST http://127.0.0.1:8080/api/orders -d '{"item": "latte", "qty": 1}'
HTTP/1.1 415 Unsupported Media Type
Content-Type: application/json; charset=utf-8
Content-Length: 51
Date: Sun, 11 Oct 2026 21:56:01 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"Expected Content-Type: application/json"}Look at the last request. Without -H, curl sets Content-Type: application/x-www-form-urlencoded on its own — that is what our server log recorded. The body still contains JSON, but the server rightly trusts the header and answers 415. This is one of the most common reasons behind “the server doesn't see my data”.
Now the same from code. fetch has been built into Node.js since version 18 and stable since 21; it behaves the same in the browser. Restart the server so order ids start over, and run the client:
const API = "http://127.0.0.1:8080/api";
async function request(method, path, body) {
const res = await fetch(API + path, {
method,
headers: body === undefined ? {} : { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = await res.json();
console.log(method, path, "→", res.status, res.ok ? "ok" : "error", data);
return data;
}
await request("GET", "/orders/1");
const created = await request("POST", "/orders", { item: "latte", qty: 1 });
await request("GET", `/orders/${created.id}`);
await request("POST", "/orders", { item: "latte", qty: 0 });
await request("GET", "/orders/999");$ node client.mjs
GET /orders/1 → 200 ok { id: 1, item: 'cappuccino', qty: 2 }
POST /orders → 201 ok { id: 2, item: 'latte', qty: 1 }
GET /orders/2 → 200 ok { id: 2, item: 'latte', qty: 1 }
POST /orders → 422 error { error: 'Fields required: item (string), qty (integer >= 1)' }
GET /orders/999 → 404 error { error: 'Order not found' }JSON: syntax, JSON.parse and JSON.stringify
JSON (JavaScript Object Notation) is defined in RFC 8259. A JSON value is an object, an array, a number, a string or one of three literals: true, false, null. The rules are stricter than JavaScript: strings and member names use double quotes only, no trailing commas, no comments, no leading zeros in numbers, and NaN and Infinity are not allowed. Between systems JSON travels as UTF-8, and the media type is application/json.
JavaScript has two functions for JSON. JSON.stringify turns a value into a string for sending, JSON.parse turns a string back into a value. Both can silently drop data or throw. This script collects the typical traps; below is real Node.js 24 output:
const order = {
id: 7,
createdAt: new Date("2026-10-11T09:30:00Z"),
comment: undefined,
discount: NaN,
format() {},
};
console.log(JSON.stringify(order));
console.log(JSON.stringify({ items: ["latte", undefined] }));
console.log(JSON.stringify({ id: 1, tags: ["new"] }, null, 2));
const broken = [
"{'item': 'latte'}",
'{"item": "latte",}',
'{"qty": 012}',
"",
];
for (const text of broken) {
try {
JSON.parse(text);
} catch (error) {
console.log(error.name + ":", error.message);
}
}
console.log(JSON.parse('{"id": 9007199254740993}').id);
try {
JSON.stringify({ total: 10n });
} catch (error) {
console.log(error.name + ":", error.message);
}
const a = { name: "a" };
a.self = a;
try {
JSON.stringify(a);
} catch (error) {
console.log(error.name + ":", error.message.split("\n")[0]);
}$ node json-pitfalls.mjs
{"id":7,"createdAt":"2026-10-11T09:30:00.000Z","discount":null}
{"items":["latte",null]}
{
"id": 1,
"tags": [
"new"
]
}
SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)
SyntaxError: Expected double-quoted property name in JSON at position 17 (line 1 column 18)
SyntaxError: Unexpected number in JSON at position 9 (line 1 column 10)
SyntaxError: Unexpected end of JSON input
9007199254740992
TypeError: Do not know how to serialize a BigInt
TypeError: Converting circular structure to JSONcomment and format are gone
- Why
undefinedand functions are skipped in objects and becomenullin arrays- What to do
- Send
nullexplicitly if the field must be present
discount became null
- Why
NaNandInfinityare not valid JSON and serialize asnull- What to do
- Validate numbers before sending
The date became a string
- Why
Datehas atoJSONmethod that returns an ISO 8601 string- What to do
- Convert the string back to a
Dateyourself when parsing
SyntaxError while parsing
- Why
- Single quotes, a trailing comma, a leading zero or an empty string
- What to do
- Wrap
JSON.parsein try/catch and answer 400
9007199254740993 became …992
- Why
- JSON numbers are parsed into 64-bit floats; RFC 8259 guarantees exact integers only up to 2^53 − 1
- What to do
- Send large ids and money amounts as strings
TypeError for BigInt and a circular reference
- Why
- There is no serialization rule for them
- What to do
- Convert the value beforehand or use a replacer
Requests in DevTools and CORS in two paragraphs
Open the Network panel
In Chrome or Edge press F12, then Network. Reload the page or click the button that sends the request. The Fetch/XHR filter keeps only API requests.
Look at the headers
Click a request. The Headers tab shows the URL, method, status code, and request and response headers.
Look at the body
Payload is what the client sent, Response is the raw server reply, Preview is the same reply in a friendlier view.
Replay it in a terminal
Right-click the request → Copy → Copy as cURL. You get a curl command with the same headers: handy for reproducing a bug outside the browser and handing it to a backend developer.
If the same request works in curl but fails in the browser with a console error, it is almost certainly CORS (Cross-Origin Resource Sharing). Under the same-origin policy the browser does not let a script read responses from another domain, port or scheme unless the server explicitly allows it with Access-Control-Allow-Origin. Curl and server-side code are not subject to this policy — it protects the user in the browser and is not a property of HTTP itself.
For “non-simple” requests — for example, a POST with Content-Type: application/json or an Authorization header — the browser first sends an OPTIONS preflight asking whether that method and those headers are allowed. JavaScript does not get the details of a CORS failure: for security reasons they are visible only in the browser console. CORS is fixed on the server, not in the frontend.
- The method matches the action: GET for reads, POST for creation, no data changes via GET
- A request with a body has the right
Content-Type - Code checks
res.okorres.statusbefore reading the body JSON.parseof incoming data is wrapped in try/catch- Large numbers and money travel as strings
- Tokens and keys go only in the
Authorizationheader, only over HTTPS and never in frontend code
How this was verified5 · passed1 · not runchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.