Requests in depth
Build precise fetch requests with HTTP methods, headers, JSON bodies, Request and Response objects, credentials, and cache options.
- 01Pick the right methodTell which methods only read and which are safe to repeat, and use GET, POST, PUT, PATCH, and DELETE on a small todo server.
- 02Send headers and JSONUse a Headers object, JSON.stringify, and the Content-Type, Accept, and Location headers.
- 03Work with Request and Response objectsBuild them, clone a body before reading it twice, and choose the credentials and cache options.
What a request is made of
In the fetch lesson, a request was simple: ask a URL for data, check response.ok, and read the JSON. Real apps also save, change, and delete data. For that, a request needs more parts.
A request can say:
- which method to use: the action you want, such as "read" or "create".
- which headers to send: short notes about the request, such as "the data I send is JSON".
- which body to send: the data itself, such as a new todo.
- whether credentials may go along: the cookies and logins the browser has saved.
- how to use the browser's cache: its store of earlier replies.
This lesson doesn't talk to a real server on the internet. A server is the computer that keeps the data and answers requests. Here, a tiny pretend server lives inside the page and keeps a list of todos in memory. The function fakeFetch(input, init) works like fetch. It makes a real Request object, hands it to the pretend server, and gives back a real Response object. The response has a status code, a JSON body, and headers such as Location (where a new todo lives) and ETag (a version label).
Picture a library help desk. What you ask for tells the librarian what kind of work to do. Asking to see a book twice does no harm. Filling in the "add this book" form twice may add the same book twice.
- In real life: May I see this book?
- In JavaScript:
GETreads something - In real life: Please add this new book
- In JavaScript:
POSTcreates something new - In real life: Replace this book with the new edition
- In JavaScript:
PUTreplaces the whole thing - In real life: Fix the typo on page 12
- In JavaScript:
PATCHchanges one part - In real life: Please remove this book
- In JavaScript:
DELETEremoves something
You post an order form to a shop. On the envelope you write short notes: "the form inside is JSON", "please reply in JSON", "here is my login". Those notes are the headers. The form inside is the request. The letter the shop sends back is the response.
- In real life: Notes written on the envelope
- In JavaScript: Headers, like
Content-Type,Accept, andAuthorization - In real life: The filled-in order form
- In JavaScript: A
Requestobject - In real life: The letter that comes back
- In JavaScript: A
Responseobject, with a status, headers, and a body - In real life: A photocopy made before you send the form
- In JavaScript:
clone(), used before you read a body twice
GET, POST, PUT, PATCH, DELETE
INTERACTIVEAn HTTP method is a word at the start of every request. It tells the server what you want to do. Each thing the server keeps, like one todo or the whole todo list, is called a resource. Each resource has its own URL, such as /api/todos/2. This style, where the URL names the resource and the method names the action, is called REST.
Two words help you describe methods:
- Safe: the method only reads. It doesn't change anything on the server.
- Idempotent: sending the same request many times leaves the server the same as sending it once.
These words matter when a request gets sent again. That happens when someone double-clicks a button, refreshes the page, or the network tries again.
const methods = [ ["PUT", "replace", true], ["PATCH", "partial change", "depends"], ["DELETE", "remove", true],]; for (const [method, action, repeatable] of methods) { console.log(method, action, repeatable);}Each row in the methods list has three parts: the method, what it does, and whether repeating it gives the same result. Line by line:
- Line 2:
PUTreplaces a whole todo. Sending the same replacement twice gives the same todo, so it saystrue. - Line 3:
PATCHchanges part of a todo, so it says"depends". A patch that setscompleted: truegives the same result every time. A patch that says "add 1 to the counter" changes the result every time. - Line 4:
DELETEremoves a todo. A second delete has nothing left to remove, so it saystrue. - Lines 7 to 9 go through the list and print one line per method, such as
PUT replace true.
The table below lists the common methods. Read each row as: this method usually does this; does it only read; and does repeating it give the same result?
| Method | What it usually does | Does it only read? (safe) | Same result if repeated? (idempotent) | Why it matters |
|---|---|---|---|---|
| GET | Reads something. | Yes. | Yes. | Reading the same thing twice changes nothing. |
| HEAD | Reads only the headers, without the body. | Yes. | Yes. | Handy for checking a file's size or type without downloading it. |
| OPTIONS | Asks which methods are allowed. | Yes. | Yes. | Browsers often send it first, before some requests to another website. |
| POST | Creates something, or sends a form. | No. | No, not usually. | Sending the same form twice may create two records. |
| PUT | Replaces a whole thing. | No. | Yes. | The same full replacement gives the same result each time. |
| PATCH | Changes one part of a thing. | No. | No, not usually. | Some patches give the same result each time, but PATCH doesn't promise it. |
| DELETE | Removes a thing. | No. | Yes. | Once it's gone, deleting it again removes nothing more. |
So GET, HEAD, and OPTIONS are safe and idempotent. PUT and DELETE change data, so they are not safe. But they are idempotent. POST and PATCH are usually not idempotent: sending the same order twice can make two orders.
POST sent twice can make two orders. The same DELETE sent twice ends the same way: todo 1 is gone.The playground below sends requests to the pretend server. The "Server data" line shows the todo list after each request. Try these in order:
GET /api/todoswith no body.POST /api/todoswith the "new title" body.PATCH /api/todos/2with the "mark completed" body.DELETE /api/todos/1.
const response = await fakeFetch("/api/todos", { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ title: "Pack a snack" }),}); console.log(response.status);console.log(response.headers.get("Location"));console.log(await response.json());- Ready
Pick a method, a URL, and a body. Then press Send request.
[{"id":1,"title":"Read the fetch reply","completed":true,"version":1},{"id":2,"title":"Practice request methods","completed":false,"version":1}]Each Send builds a real Request, gives it to a small todo server that lives in this page, and shows the real Response. Reset puts the todos back.
One rule to know: a GET or HEAD request can't have a body. If you give it one, the Request constructor throws a TypeError.
- GET /api/todos
- HEAD /pricing
- PUT /api/todos/2 with the whole new todo
- DELETE /api/todos/2
- POST /api/orders
- PATCH /api/todos/2 that adds 1 to a counter
Sort each request: does it only read, does it change data but give the same result when repeated, or can each repeat do more?
Headers
LABHeaders are short name-and-value notes sent with a request or a response. For example, Content-Type: application/json says "the body is JSON". JavaScript keeps headers in a Headers object. It has a few methods:
append(name, value)adds one more value to a header.set(name, value)replaces every value the header had.get(name)reads a header. If it has several values, you get them joined with a comma.
Header names ignore upper and lower case. Content-Type, content-type, and CONTENT-TYPE are the same header.
const headers = new Headers();headers.append("Accept", "application/json");headers.append("accept", "text/plain");headers.set("Content-Type", "application/json"); console.log(headers.get("ACCEPT"));console.log([...headers]);Lines 2 and 3 add two values to the same header, Accept. Line 3 writes the name in lower case, but it is still the same header. Line 4 sets Content-Type. Line 6 reads ACCEPT and prints application/json, text/plain. Line 7 prints every header as a [name, value] pair, with the names in lower case.
append calls give Accept two values. A later set throws both away and keeps only the new one.const headers = new Headers();headers.append("Accept", "application/json");headers.append("accept", "text/plain");headers.set("Content-Type", "application/json"); console.log(headers.get("ACCEPT"));console.log([...headers]);- Ready
Press the button to run the lab in your browser.
append adds a value, set replaces, and get ignores case. Some headers, like Host and Cookie, belong to the browser, so your code shouldn't count on setting them.
Some headers belong to the browser. Your code shouldn't try to set Host (the server's name) or Cookie (saved login data). The browser decides what to send for those. The lab shows what your browser lets a Headers object store. But storing a header in the object doesn't mean the browser will send it.
FormData holds form fields, and it can hold files. When the body is FormData, don't set Content-Type yourself. The browser sets it to multipart/form-data plus a boundary: a made-up marker that separates the fields in the body. Only the browser knows that marker. URLSearchParams works the same way: the browser labels it as form data for you.
Sending JSON
STEP THROUGHJSON is a text format for data. To send JSON, you need three things:
- a method that can carry a body, such as
POST. - the header
Content-Type: application/json, so the server knows the body is JSON. - the data turned into text with
JSON.stringify.
The JSON lesson explains why JSON is text and not a live object.
async function createTodo(title) { const headers = new Headers(); headers.set("Accept", "application/json"); headers.set("Content-Type", "application/json"); const body = JSON.stringify({ title }); const response = await fakeFetch("/api/todos", { method: "POST", headers, body, }); if (!response.ok) { const problem = await response.json(); throw new Error(response.status + " " + problem.message); } const location = response.headers.get("Location"); const todo = await response.json(); return { location, todo };}Line by line:
- Lines 2 to 4 make a
Headersobject.Acceptasks for JSON back.Content-Typesays the body is JSON. - Line 6 turns
{ title }into JSON text. - Lines 7 to 11 send it to
/api/todoswithPOST. - Line 13 checks
response.ok. If the server said no, line 14 reads the server's error message, and line 15 throws anError. - Line 18 reads the
Locationheader. Line 19 reads the new todo. Line 20 returns both.
createTodo sends a POST with a JSON body. The server saves the todo and replies 201, with a Location header that says where the new todo lives.Pick a title, then step through what the pretend server really did.
script
const headers = new Headers(); headers.set("Accept", "application/json"); headers.set("Content-Type", "application/json"); const body = JSON.stringify({ title }); const response = await fakeFetch("/api/todos", { method: "POST", headers, body, }); if (!response.ok) { const problem = await response.json(); throw new Error(response.status + " " + problem.message); } const location = response.headers.get("Location"); const todo = await response.json(); return { location, todo };}Pick "empty title" or "duplicate title" above the replay. The request still reaches the pretend server, but the server says no: 422 for an empty title, and 409 for a title that already exists. The helper reads the server's JSON error message and throws an Error with it.
A status code is a number in every response. It says how the request went.
201 Created: the todo was made. The response has aLocationheader with the new todo's URL.409 Conflict: a todo with that title already exists.422 Unprocessable Content: the body was valid JSON, but the data was wrong, such as an empty title.
Request and Response objects
OBJECTSfetch can take a URL plus an options object. It can also take a ready-made Request object. A Request holds everything about one request: the URL, the method, the headers, the body, and the other options. You can build it now and send it later.
A Response holds the server's answer: the status code, the headers, and the body.
The body of a request or a response is a stream: data that arrives in pieces and can be read only once. Sometimes you need to read a body twice, for example once to log it and once to use it. Then call clone() first. clone() makes a copy with its own body. You met the "read once" rule with response.json() in the fetch lesson. Here it applies to requests too.
const request = new Request("/api/todos", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "Clone me" }),}); const copy = request.clone();console.log(await request.text());console.log(await copy.json()); const response = Response.json( { saved: true }, { status: 201, headers: { Location: "/api/todos/3" } },);console.log(response.status);console.log(response.headers.get("content-type"));- Ready
Pick a button, then press Run object lab.
A request or response body can be read only once. Clone it before reading it twice. Response.json is a short way to build a JSON response, in browsers that have it.
const request = new Request("/api/todos", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "Clone me" }),}); const copy = request.clone();console.log(await request.text());console.log(await copy.json()); const response = Response.json( { saved: true }, { status: 201, headers: { Location: "/api/todos/3" } },);console.log(response.status);console.log(response.headers.get("content-type"));Line 7 clones the request before anything reads it. Line 8 reads the original as text. Line 9 reads the copy as JSON. Lines 11 to 14 build a 201 response with Response.json. Line 15 prints 201. Line 16 prints its content-type header, which is application/json.
(async () => {const request = new Request("https://example.test/todos", { method: "POST", headers: { Accept: "application/json" }, body: "title=Pack%20tea",}); console.log(request.method);console.log(request.headers.get("accept"));console.log(await request.text());})();Lines 1 and 11 wrap the code in an async function, so it can use await. Line 2 builds a POST request. Nothing is sent to a server. Line 8 prints POST. Line 9 asks for accept in lower case and still finds application/json. Line 10 reads the body as text and prints title=Pack%20tea. (%20 is how a space is written in this URL-style text.) After line 10, the body is used up.
const missing = new Response("missing", { status: 404, statusText: "Not Found",});const broken = new Response("failed", { status: 500, statusText: "Server Error" }); console.log(missing.status, missing.statusText, missing.ok);console.log(broken.status, broken.statusText, broken.ok);Lines 1 to 5 build two responses by hand: a 404, which means "not found", and a 500, which means "the server failed". Line 7 prints 404 Not Found false. Line 8 prints 500 Server Error false. ok is true only for status codes from 200 to 299. statusText is a short reason in words. Check the number, not the words.
const response = Response.redirect("https://example.test/login", 302); console.log(response.status);console.log(response.headers.get("location"));console.log(response.redirected);A redirect is a reply that says "look at this other URL instead". Line 1 builds a 302 redirect response with Response.redirect. Line 3 prints 302. Line 4 prints the location header, which holds the new URL. Line 5 prints false. redirected is true only when fetch really followed a redirect. This response was built by hand, so it is false.
bodyUsed tells you whether the body has been read. It starts as false. It becomes true as soon as something starts reading the body. A reader reads a body in pieces, called chunks. That lets a page handle a large reply one piece at a time.
(async () => {const response = new Response("hello");const reader = response.body.getReader(); console.log(response.bodyUsed);const first = await reader.read();console.log(new TextDecoder().decode(first.value), first.done);console.log(response.bodyUsed);console.log((await reader.read()).done);})();- Line 2 makes a response with the body
"hello". No network is used. - Line 3 gets a reader for the body.
- Line 5 prints
false: nothing has been read yet. - Line 6 reads the first chunk. Line 7 turns its bytes back into text with
TextDecoderand printshello false. Herefalsemeans the stream isn't finished yet. - Line 8 prints
true, because reading has started. - Line 9 reads again and prints
true: the stream is finished.
(async () => {const response = new Response(JSON.stringify({ name: "Asha" }));const copy = response.clone(); console.log((await response.json()).name);console.log((await copy.json()).name);})();Line 3 makes the copy before anything reads the body. Line 5 reads the original and prints Asha. Line 6 reads the copy and also prints Asha. Each one had its own body. Only clone when two parts of your code really need the body: the browser may have to keep a second copy of the data in memory.
clone() makes a copy with its own body before anything is read. Each body is read once, so both print Asha.const request = new Request("https://example.test/profile", { method: "POST", mode: "cors", credentials: "include", cache: "no-store", redirect: "manual", keepalive: true, body: "name=Asha",}); console.log(request.mode, request.credentials);console.log(request.cache, request.redirect, request.keepalive);Three of these options are new:
modesays which rules apply to requests to other websites."cors"allows a request to another website only if that website agrees.redirectsays what to do with a redirect: follow it, fail, or hand it to your code.keepalivelets a small request finish even while the page is closing.
Line 11 prints cors include. Line 12 prints no-store manual true. The Request keeps every choice. But these options don't give your code extra powers: the browser and the server still apply their own rules.
Response.json(data, init) builds a response with a JSON body. It also sets the Content-Type header for you. Browsers added it in 2023, so the lab checks that it exists before using it. For older browsers, use new Response(JSON.stringify(data), ...) instead.
credentials and cache options
REAL CHECKCredentials are login details the browser keeps for you: cookies, HTTP logins, and client certificates. The credentials option says whether they may go with the request.
An origin is the start of a URL: its scheme, domain, and port, such as https://example.com. A request to the page's own origin is same-origin. A request to any other origin is cross-origin.
The default is "same-origin": credentials go only to the page's own origin. To send them cross-origin, you need "include", and the other server must allow it with CORS headers. CORS is the topic of the next lesson.
The table below lists the three values. Read each row as: with this value, this is what the browser sends.
| Value | What the browser does |
|---|---|
| omit | Sends no cookies, HTTP logins, or client certificates. |
| same-origin | The default. Sends them only to the page's own origin. |
| include | Sends them to other origins too. The other server must allow this with CORS headers. |
for (const credentials of ["omit", "same-origin", "include"]) { const request = new Request("https://example.test/profile", { credentials }); console.log(credentials, request.credentials);}The loop makes one Request per value. It prints each value twice: once from the list, and once from the request. They match, because the Request keeps what you chose. Use omit for public data. Use the default, same-origin, for normal requests to your own site. Use include only to send credentials to another origin that allows it.
The browser's HTTP cache is a store of earlier replies. When a saved reply is still good, the browser can use it instead of asking the server again. The cache option tells the browser how to use that store for one request. It doesn't make the server any faster.
Read each row of the next table as: with this value, this is how the browser uses its cache.
| Value | What the browser does |
|---|---|
| default | Uses the browser's normal cache rules. |
| no-store | Doesn't look in the cache, and doesn't save the reply there. |
| reload | Skips the cache and asks the server. Then saves the new reply in the cache. |
| no-cache | May use a saved reply, but only after checking with the server that it is still current. |
| force-cache | Uses a saved reply if there is one, even an old one. Asks the server only if there is none. |
| only-if-cached | Uses only the cache and never asks the server. In browsers, it works only with mode: "same-origin". |
const request = new Request("https://example.test/stats", { cache: "no-store",}); console.log(request.cache);Line 1 builds a request for a stats URL. Line 2 picks no-store. Line 5 prints no-store. With this value, the browser doesn't look in its cache, and it doesn't save the reply there. It still needs the server, so this is not an offline mode.
Three more options, for quick reference. Read each row as: this option answers this question.
| Option | The question it answers | Example |
|---|---|---|
| mode | Which rules apply to requests to other websites? | cors allows a request to another website only if that website agrees. |
| redirect | What happens when the server says "look at another URL"? | follow goes to the new URL, error fails, and manual hands the redirect to your code. |
| keepalive | Can a small request finish while the page closes? | true helps send a short "the user left the page" message. |
await fetch(location.href, { cache: "default" });await fetch(location.href, { cache: "no-store" }); await fetch("/api/profile", { credentials: "omit" });await fetch("/api/profile", { credentials: "same-origin" });await fetch("/api/profile", { credentials: "include" });- Ready
Press the button to send two HEAD requests to this page.
The real requests go only to this page. If default and no-store look the same, that is normal: most cache effects only show with server cache headers and repeat visits.
redirect: "follow" is the default: fetch follows a redirect to the new URL. "error" makes fetch fail on a redirect. "manual" hands the redirect response to your code. keepalive: true is for small requests sent while the page closes, such as "the user left the page". It is not for big uploads.
The lab sends two HEAD requests to this page. HEAD asks for the headers only, without the body. One request uses cache: "default", and one uses cache: "no-store". On a page like this one, the status and headers often look the same. That is expected: a cache value is only an instruction to the browser. To see a difference, you usually need cache headers from the server, or the browser's developer tools.
Where you will use this
PRACTICALYou'll use these parts in every app that saves data: todo lists, dashboards, profile pages, checkouts, admin tools, and search pages. Each time, you pick a method, add headers, turn the body into text, check the status, and read the body once.
- Loading a dashboard:
GET /api/stats. Addcache: "no-store"if the numbers must be fresh. - Creating a todo:
POST /api/todoswith a JSON body. The reply has aLocationheader. - Saving a whole profile form:
PUT /api/profile, because the form replaces the whole saved profile. - Ticking one checkbox:
PATCH /api/todos/2with only{ completed: true }. - Stopping a slow request: add an AbortController signal to the same options.
Common misconceptions
WATCH OUT- "POST always creates something."
POSTusually sends data to a list or an action. But the server decides what each URL does. - "Idempotent means the reply is always the same." No. It means the data on the server ends up the same. The status can change: a second
DELETEmay get404, because the todo is already gone. - "Header names care about upper and lower case." They don't.
headers.get("content-type")findsContent-Type. - "I should set Content-Type for every body." Set it for JSON. Don't set it for
FormData; the browser does that. - "I can read a request body as often as I like." A body is a stream, and it can be read once. Clone first if you need two reads.
- "credentials: include gets around CORS." It doesn't. To send credentials to another origin, that server must allow it with CORS headers.
The table below lists request parts that are easy to mix up. Read each row as: this part answers this question.
| Part | The question it answers | Example |
|---|---|---|
| Method | What do I want the server to do? | PATCH means change one part. |
| Content-Type | What format is the body I send? | application/json means JSON text. |
| Accept | What format do I want back? | application/json asks for JSON. |
| credentials | May cookies and logins go along? | same-origin is the default. |
| cache | How should the browser use its saved replies? | no-store skips the cache completely. |
Practice exercises
5 EXERCISESYou want to add a new todo to the list at /api/todos. Which method do you usually use?
Use POST. It is the usual method for sending something new to a list, like a new todo.
This code builds a GET request with a body. What error name does it print?
try {
new Request("https://example.test/api", {
method: "GET",
body: "hello",
});
} catch (error) {
console.log(error.name);
}try {
new Request("https://example.test/api", {
method: "GET",
body: "hello",
});
} catch (error) {
console.log(error.name);
}The Request constructor throws a TypeError, because a GET request can't have a body. The catch prints its name.
What two lines does this print?
const headers = new Headers();
headers.append("Accept", "application/json");
headers.append("accept", "text/plain");
console.log(headers.get("ACCEPT"));
headers.set("Accept", "application/xml");
console.log(headers.get("accept"));const headers = new Headers();
headers.append("Accept", "application/json");
headers.append("accept", "text/plain");
console.log(headers.get("ACCEPT"));
headers.set("Accept", "application/xml");
console.log(headers.get("accept"));Line 4 prints both values joined with a comma: application/json, text/plain. Line 5 replaces them with one value, so line 6 prints application/xml.
What two lines does this print?
const request = new Request("https://example.test/api", {
method: "POST",
body: JSON.stringify({ title: "Clone" }),
});
const copy = request.clone();
console.log(await request.text());
console.log((await copy.json()).title);const request = new Request("https://example.test/api", {
method: "POST",
body: JSON.stringify({ title: "Clone" }),
});
const copy = request.clone();
console.log(await request.text());
console.log((await copy.json()).title);Line 6 prints the original body as JSON text: {"title":"Clone"}. The copy still has its own unread body, so line 7 reads it as JSON and prints its title, Clone.
You send a FormData body. Should you set the Content-Type header yourself?
No. Leave Content-Type out. The browser sets it to multipart/form-data with the boundary that matches the body.
Check your understanding
8 QUESTIONSQuestion 1 of 8Which method only reads, and gives the same result when repeated?
Choose an answer to see the explanation.
Question 2 of 8What does this print?
Read the code, then predictconst headers = new Headers(); headers.append("Accept", "application/json"); headers.append("accept", "text/plain"); console.log(headers.get("ACCEPT"));Choose an answer to see the explanation.
Question 3 of 8Which JSON request is correct?
Choose an answer to see the explanation.
Question 4 of 8What error name does this print?
Read the code, then predicttry { new Request("https://example.test/api", { method: "GET", body: "x" }); } catch (error) { console.log(error.name); }Choose an answer to see the explanation.
Question 5 of 8Why clone a Request or a Response?
Choose an answer to see the explanation.
Question 6 of 8Which credentials value does fetch use by default?
Choose an answer to see the explanation.
Question 7 of 8What should you do about Content-Type when the body is FormData?
Choose an answer to see the explanation.
Question 8 of 8What does cache: "no-store" mean?
Choose an answer to see the explanation.
Key takeaways
- A method says what you want:
GETreads,POSTsends something new,PUTreplaces,PATCHchanges one part, andDELETEremoves. GET,HEAD, andOPTIONSare safe: they only read.GET,HEAD,OPTIONS,PUT, andDELETEare idempotent: repeating them leaves the same result.- Header names ignore case.
setreplaces a header's values;appendadds one more value. - To send JSON, use a method that can carry a body, set
Content-Type: application/json, and useJSON.stringify. - A request or response body can be read only once. Call
clone()before reading it twice. credentialsissame-originby default. Thecacheoption tells the browser how to use its saved replies.
Final definition: A full fetch request has a method, a URL, headers, a body, and credentials and cache settings. The answer is a Response. Its status, headers, and read-once body tell you what the server said.
Up next: CORS & cross-origin requests.