⚙️ Request Configuration Options
A URL tells fetch where to go. The optional second argument — the options object — tells it how to get there: which HTTP method to use, what headers and body to send, whether to include cookies, and how to handle caching and cancellation. This lesson is your field guide to every option that matters.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Pass an options object to
fetch()and set the HTTPmethod - Send request headers and choose the right
Content-Typefor your body - Attach a body as JSON,
FormData, orURLSearchParams - Explain
mode,credentials, andcache, and when each matters - Use
signalwithAbortControllerto cancel or time out a request
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Build a small request() wrapper that merges default options with per-call overrides.
In This Lesson
The Options Object
Every fetch you've written so far used a single argument. The full signature is fetch(url, options), where options is a plain object whose properties fine-tune the request:
fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Ada', email: 'ada@example.com' }),
})
.then((response) => response.json())
.then((data) => console.log(data));
💡 An analogy: If the URL is the address on an envelope, the options object is everything else that governs delivery — first class or ground (method), the notes on the outside (headers), the contents inside (body), whether it's tracked and signed-for (credentials), and a way to recall it mid-transit (signal).
method, headers, and body constantly; the rest come up in specific situations.HTTP Methods with method
The method property sets the HTTP verb. It defaults to 'GET'. Each verb signals a different intent to the server, and REST APIs lean on that convention heavily.
| Method | Intent | Library analogy |
|---|---|---|
| GET | Read a resource (no changes) | Reading a book on the shelf |
| POST | Create a new resource | Adding a new book to the collection |
| PUT | Replace a resource entirely | Swapping in a whole new edition |
| PATCH | Update part of a resource | Correcting a few pages |
| DELETE | Remove a resource | Taking a book out of circulation |
// GET (default) — just read
await fetch('https://api.example.com/users');
// POST — create
await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }),
});
// PATCH — partial update
await fetch('https://api.example.com/users/123', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'new@example.com' }),
});
// DELETE — remove
await fetch('https://api.example.com/users/123', { method: 'DELETE' });
💡 GET and HEAD carry no body
The spec forbids a request body on GET and HEAD. If you need to pass parameters on a GET, put them in the query string (see URLSearchParams below), not the body.
Request Headers with headers
Headers are metadata about your request — who you are, what format you're sending, what you'd like back. You can pass a plain object or a Headers instance.
// Plain object — simplest
await fetch('https://api.example.com/data', {
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer eyJhbGciOiJIUzI1Ni...',
'Accept': 'application/json',
},
});
// Headers object — handy for building headers dynamically
const headers = new Headers();
headers.append('Content-Type', 'application/json');
headers.set('Authorization', `Bearer ${token}`);
if (headers.has('Authorization')) {
console.log(headers.get('Authorization'));
}
await fetch('https://api.example.com/data', { headers });
| Header | Purpose |
|---|---|
Content-Type | The format of the body you're sending (e.g. application/json) |
Authorization | Credentials — commonly a bearer token |
Accept | The format you'd like the response in |
Cache-Control | Caching directives for the request |
⚠️ Some headers are off-limits
The browser controls "forbidden" headers such as Host, Connection, Content-Length, and Cookie. Attempts to set them are silently ignored — the browser fills them in correctly for you.
The Request Body with body
The body carries the data you're sending, for methods like POST, PUT, and PATCH. Its type determines how you prepare it — and often which Content-Type to set (or not set).
JSON — the everyday case
await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'John Doe', age: 30 }),
});
FormData — files and multipart
const formData = new FormData();
formData.append('username', 'johndoe');
formData.append('avatar', fileInput.files[0]);
await fetch('https://api.example.com/profile', {
method: 'POST',
body: formData, // note: do NOT set Content-Type yourself
});
⚠️ Don't set Content-Type for FormData
With FormData, the browser must generate a multipart/form-data header that includes a unique boundary string. If you hard-code Content-Type yourself, you'll omit that boundary and the server won't be able to parse the upload. Let the browser handle it.
URLSearchParams — form-encoded data
const params = new URLSearchParams();
params.append('search', 'query term');
params.append('sort', 'ascending');
await fetch('https://api.example.com/search', {
method: 'POST',
body: params, // sets application/x-www-form-urlencoded automatically
});
Mode & Credentials
mode — cross-origin behavior
The mode option governs how the request deals with CORS (Cross-Origin Resource Sharing), the browser rule that a page may only read responses from other origins when the server opts in.
'cors'(default) — a normal cross-origin request; the server must send the rightAccess-Control-*headers for you to read the response.'same-origin'— fail outright if the URL is on a different origin.'no-cors'— allowed for a narrow set of "simple" requests, but you get an opaque response whose body you cannot read.
⚠️ no-cors does not bypass CORS
A common myth is that mode: 'no-cors' "turns off" CORS restrictions. It does the opposite — it produces a more restricted, opaque response you can't inspect. The real fix for a CORS error is to configure the server, or route through your own backend proxy.
credentials — cookies and auth
This controls whether cookies and HTTP-auth headers ride along with the request:
'same-origin'(default) — send credentials only to the same origin.'include'— always send them, even cross-origin.'omit'— never send them.
// Send the session cookie to a cross-origin API
await fetch('https://api.example.com/profile', {
credentials: 'include',
});
💡 The server must agree
For credentials: 'include' to work cross-origin, the server must respond with Access-Control-Allow-Credentials: true and an explicit origin (not the * wildcard). Otherwise the browser blocks the response.
Cache, Redirect & Signal
cache — talking to the HTTP cache
The cache option decides how the request interacts with the browser's HTTP cache:
| Value | Behavior |
|---|---|
'default' | Normal caching rules apply |
'no-store' | Never read from or write to the cache — good for real-time data |
'no-cache' | Revalidate with the server before using a cached copy |
'reload' | Bypass the cache for the request, but store the fresh response |
'force-cache' | Use a cached copy even if stale |
redirect
Controls redirect handling: 'follow' (default, follow automatically), 'error' (reject on a redirect), or 'manual' (hand you the redirect to deal with yourself).
signal — cancellation and timeouts
Perhaps the most useful advanced option. An AbortController produces a signal you pass into fetch; calling controller.abort() cancels the in-flight request. This powers timeouts and "cancel the old search when a new keystroke arrives" patterns.
// A fetch that times out after 5 seconds
async function fetchWithTimeout(url, ms = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ms);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
throw new Error(`Request timed out after ${ms}ms`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
💡 Modern shortcut
Recent browsers support AbortSignal.timeout(5000), which you can pass directly as signal to get a timeout without wiring up your own setTimeout. The manual version above still works everywhere and shows what's happening under the hood.
Hands-on Exercise
🏋️ Build a configurable request() wrapper
Objective: Combine what you've learned into one function that holds sensible defaults and lets each call override them — the seed of a real API client.
Instructions:
- Write
request(url, options = {})that merges a set of default headers with anyoptions.headersthe caller passes. - Default the
Content-Typetoapplication/jsonandcredentialstosame-origin. - Add a 8-second timeout using
AbortControllerandsignal. - Throw a clear error when
!response.ok, and otherwise return parsed JSON. - Test it with a GET to
https://jsonplaceholder.typicode.com/posts/1and a POST that creates a post.
💡 Hint
Merge order matters: spread the defaults first, then the caller's options, and merge headers as a nested object so a caller can add a header without wiping your defaults — headers: { ...defaults.headers, ...options.headers }.
✅ Sample solution
async function request(url, options = {}) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const defaults = {
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
};
try {
const response = await fetch(url, {
...defaults,
...options,
headers: { ...defaults.headers, ...options.headers },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return await response.json();
} catch (error) {
if (error.name === 'AbortError') throw new Error('Request timed out');
throw error;
} finally {
clearTimeout(timeoutId);
}
}
// GET
const post = await request('https://jsonplaceholder.typicode.com/posts/1');
// POST
const created = await request('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
body: JSON.stringify({ title: 'Hello', body: 'World', userId: 1 }),
});
🎯 Quick Quiz
Question 1: You're uploading a file with FormData. What should you do about the Content-Type header?
Question 2: What does mode: 'no-cors' actually do?
Question 3: Which option lets you cancel an in-flight fetch (for a timeout or a superseded search)?
Best Practices
✅ Do
- Be explicit about
methodandContent-Type— it makes intent obvious to readers. - Centralize defaults in a wrapper and merge per-call overrides carefully (spread headers as a nested object).
- Add a timeout via
signalso requests can't hang forever. - Reach for
credentials: 'include'only when you truly need cross-origin cookies.
⚠️ Don't
- Don't set
Content-Typewhen the body isFormData. - Don't expect
no-corsto solve a CORS error — fix the server or proxy instead. - Don't attach a
bodyto aGETorHEADrequest; it's not allowed.
Summary & Quiz
🎉 Key Takeaways
- The options object is fetch's control panel:
method,headers,body, and more. - Match the body type to the job — JSON for structured data, FormData for uploads, URLSearchParams for form encoding.
modeandcredentialsgovern cross-origin behavior;no-corsrestricts rather than frees.signal+AbortControllergive you cancellation and timeouts.- Wrapping defaults in a small helper keeps your requests consistent and DRY.
📚 Further Reading
🚀 What's Next?
You now control exactly how a request goes out. Next we turn to what comes back — processing the Response object in depth: status properties, headers, body methods, cloning, and streaming.
🎉 Well done!
With the options object in hand, you can send any request an API asks for. Let's decode the responses next.