The Fetch API
By Flavio Copes
Learn the Fetch API through practical GET, POST, form, error, cancellation, credentials, caching, and streaming examples.
The Fetch API is the standard way to make HTTP requests from JavaScript.
You can use it in browsers, Web Workers, Cloudflare Workers, and modern Node.js releases. The same Request, Response, Headers, and FormData APIs appear across those environments.
The behavior is defined by the living Fetch Standard. Fetch is built into the platform, so there is no package to install.
Fetch looks simple:
const response = await fetch('/api/todos')
But most Fetch bugs happen after this line.
A 404 does not reject the promise. A response body can be read only once. Sending JSON requires different headers than sending a form. Cookies, CORS, timeouts, and streams each have their own rules.
Let’s build the right mental model first, then use it in real requests.
How Fetch works
Every Fetch request has 3 main parts:
- A URL or
Requestobject describing where to send the request. - An optional configuration object describing the method, headers, body, and other behavior.
- A
Responseobject containing the status, headers, and body returned by the server.
fetch() returns a promise. That promise resolves when the response headers are available.
The body may still be arriving over the network. Reading it is a second asynchronous operation:
const response = await fetch('/api/todos')
const todos = await response.json()
This is why we use 2 await expressions. The first waits for the response. The second reads and parses its body.
If async and await are new to you, learn those first. Fetch becomes much easier once promises make sense. My free JavaScript course puts these concepts in the right order.
Make a GET request
Fetch uses GET by default. You only need to pass the URL:
const response = await fetch('/api/todos')
A URL starting with / is relative to the current origin. If your page runs at https://app.example.com, the request goes to:
https://app.example.com/api/todos
You can also pass an absolute URL:
const response = await fetch('https://api.example.com/todos')
Use an absolute URL in Node.js, where there is no current page to resolve a relative URL against.
Read a JSON response
Most APIs return JSON. Call response.json() to parse it:
async function loadTodos() {
const response = await fetch('/api/todos')
const todos = await response.json()
console.log(todos)
}
loadTodos()
json() reads the complete body and parses it with JSON.parse(). It rejects if the body is not valid JSON.
The method name can be misleading. It does not tell the server to send JSON. It only decides how JavaScript reads the response.
You can ask the server for JSON with the Accept header:
const response = await fetch('/api/todos', {
headers: {
Accept: 'application/json',
},
})
The server still decides what it returns. Check its API contract before choosing a body-reading method.
Check for HTTP errors
This is the most important Fetch rule:
Fetch rejects on request and network failures. It does not reject just because the server returns an HTTP error.
A 404 Not Found or 500 Internal Server Error is still a valid HTTP response. The Fetch promise resolves with that response.
Check response.ok before treating the request as successful:
async function loadTodos() {
const response = await fetch('/api/todos')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json()
}
response.ok is true for status codes from 200 through 299.
You can also inspect the exact status:
if (response.status === 404) {
console.log('No todos found')
}
Do not depend on response.statusText. It can be empty, especially with HTTP/2. Use status or ok for application logic.
Handle the different kinds of failure
A useful interface treats HTTP, network, parsing, and cancellation failures differently.
Start with one try...catch around both fetch() and body parsing:
async function loadTodos() {
try {
const response = await fetch('/api/todos')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const todos = await response.json()
console.log(todos)
} catch (error) {
console.error('Could not load todos', error)
}
}
The catch block can receive:
- a
TypeErrorfor many network, URL, and CORS failures - the HTTP error you throw after checking
response.ok - a
SyntaxErrorif the response is not valid JSON - an abort or timeout error when a signal cancels the request
Browsers deliberately hide some network details from JavaScript. Open the Network panel and Console when the message is too generic.
Build a small JSON helper
If an application talks to a JSON API in many places, centralize the repeated checks:
async function fetchJson(url, options = {}) {
const headers = new Headers(options.headers)
headers.set('Accept', 'application/json')
const response = await fetch(url, {
...options,
headers,
})
if (!response.ok) {
const message = (await response.text()).slice(0, 200)
throw new Error(message || `HTTP ${response.status}`)
}
if (response.status === 204 || response.status === 205) {
return null
}
return response.json()
}
This helper assumes every successful response contains JSON, except 204 No Content and 205 Reset Content. It also treats error bodies as text. Those are application rules, not Fetch rules.
Keep helpers tied to a clear API contract. A universal wrapper that guesses every possible response format usually becomes harder to understand than fetch() itself.
Add query parameters safely
Do not build query strings by joining raw values. Use URL and URLSearchParams so characters are encoded correctly:
const url = new URL('/api/todos', location.origin)
url.searchParams.set('status', 'open')
url.searchParams.set('search', 'read & learn')
const todos = await fetchJson(url)
The final URL looks like this:
/api/todos?status=open&search=read+%26+learn
Use append() when the same parameter can appear more than once:
url.searchParams.append('tag', 'javascript')
url.searchParams.append('tag', 'browser')
Send JSON with POST
Use POST when the server expects you to create something.
JSON requires 3 pieces:
- a method
- a
Content-Typeheader - a string body created with
JSON.stringify()
Example:
async function createTodo(title) {
return fetchJson('/api/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title }),
})
}
const todo = await createTodo('Learn the Fetch API')
Content-Type describes the request body you are sending. Accept describes the response format you want back.
Fetch supports PUT, PATCH, and DELETE in the same way:
await fetchJson('/api/todos/42', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ completed: true }),
})
Do not attach a body to GET or HEAD. Put filters in the URL instead.
Submit a form
FormData lets Fetch send the same fields as a native HTML form.
Start with a working form:
<form id="todo-form" action="/api/todos" method="post">
<label>
Todo
<input name="title" required>
</label>
<button>Add todo</button>
</form>
Then enhance it with JavaScript:
const form = document.querySelector('#todo-form')
form.addEventListener('submit', async event => {
event.preventDefault()
const response = await fetch(form.action, {
method: form.method,
body: new FormData(form),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
})
Do not set the Content-Type header when sending FormData. The browser generates the multipart boundary and adds it to the header. I wrote a separate guide about this common file-upload bug.
A file input is included automatically:
<input name="attachment" type="file">
Every form control needs a name. A control without one is not included in FormData.
The server must still validate every field and file. Fetch changes how the page submits. It does not make the input safe.
Send URL-encoded data
Some endpoints expect application/x-www-form-urlencoded instead of JSON or multipart data.
Pass a URLSearchParams object as the body:
const body = new URLSearchParams({
title: 'Learn Fetch',
priority: 'high',
})
const response = await fetch('/api/todos', {
method: 'POST',
body,
})
The browser serializes the values and sets the matching content type.
Read other response formats
JSON is only one possible response body.
Fetch gives you these common methods:
text()returns a stringjson()parses JSONblob()returns aBlob, useful for browser files and imagesarrayBuffer()returns raw binary dataformData()parses a form-encoded response
Every method returns a promise:
const response = await fetch('/notes.txt')
const text = await response.text()
For an image in the browser:
const response = await fetch('/images/avatar.png')
const image = await response.blob()
const imageUrl = URL.createObjectURL(image)
Remember to release object URLs when you no longer need them:
URL.revokeObjectURL(imageUrl)
A response body can be read only once
Response bodies are streams. Once a body reader consumes the stream, another reader cannot consume it again:
const response = await fetch('/api/todos')
const todos = await response.json()
const text = await response.text() //TypeError
The response.bodyUsed property tells you whether reading has started.
If you genuinely need 2 readers, clone the response before reading it:
const response = await fetch('/api/todos')
const copy = response.clone()
const todos = await response.json()
const raw = await copy.text()
Cloning is useful for logging or caching, but avoid it for large bodies unless both consumers keep up. The unread side may be buffered in memory.
Inspect status, headers, and redirects
The Response object contains useful metadata:
const response = await fetch('/api/todos')
console.log(response.ok)
console.log(response.status)
console.log(response.url)
console.log(response.redirected)
console.log(response.headers.get('Content-Type'))
Fetch follows redirects by default. response.url contains the final URL, and response.redirected tells you whether a redirect happened.
Use redirect: 'error' when a redirect should fail the request:
const response = await fetch('/api/todos', {
redirect: 'error',
})
You can iterate over visible response headers:
for (const [name, value] of response.headers) {
console.log(name, value)
}
For cross-origin responses, CORS decides which headers JavaScript can read.
Create a Request object
Most application code can pass a URL and options directly to fetch().
Use a Request object when you want to prepare or inspect a request first:
const request = new Request('/api/todos', {
headers: {
Accept: 'application/json',
},
})
console.log(request.method)
console.log(request.url)
const response = await fetch(request)
Request bodies are also one-use streams. Clone a request before sending it if another consumer needs the same body.
Send an authorization header
APIs often accept a bearer token in the Authorization header:
const response = await fetch('/api/account', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
Never put a private server API key in browser JavaScript. Anyone can inspect the source and Network panel. Call that service from your server instead.
Send cookies
Fetch sends credentials to same-origin URLs by default. For cookies, you can control this with the credentials option:
fetch('/api/account', {
credentials: 'same-origin',
})
The 3 values are:
same-origin, the default, sends cookies to the same originincludealso allows cookies on cross-origin requestsomitsends no cookies and ignores cookie-setting responses
A cross-origin cookie request needs matching CORS headers on the server:
fetch('https://api.example.com/account', {
credentials: 'include',
})
Cookie rules such as SameSite and Secure still apply. credentials: 'include' does not override them, and browser privacy settings may still block third-party cookies.
Cookie-authenticated routes that change data still need CSRF protection on the server.
Understand CORS
Browser JavaScript cannot freely read responses from every origin. The other server must allow your origin using CORS.
You cannot fix a missing CORS response header from frontend code.
On cross-origin requests, methods such as PUT and DELETE, an Authorization header, or a JSON Content-Type can trigger an OPTIONS preflight request. The server must allow that preflight before the browser sends the real request.
This does not fix it:
fetch('https://api.example.com/todos', {
mode: 'no-cors',
})
It gives JavaScript an opaque response. Its status is 0, and you cannot read its headers or body.
CORS is also not authorization. A server must still authenticate requests and check permissions.
Cancel a request
Use AbortController when the user leaves, starts a newer request, or clicks a cancel button.
Pass the controller’s signal to Fetch:
const controller = new AbortController()
const request = fetch('/api/todos', {
signal: controller.signal,
})
controller.abort()
await request
The final await rejects with an AbortError.
For a timeout, create a signal that aborts automatically:
const response = await fetch('/api/todos', {
signal: AbortSignal.timeout(5000),
})
This rejects with a TimeoutError after 5 seconds.
Read my AbortController tutorial for user cancellation, superseded searches, component cleanup, combined signals, and a real example from Port Pilot.
Run independent requests in parallel
If 2 requests do not depend on each other, start them together:
const [todos, labels] = await Promise.all([
fetchJson('/api/todos'),
fetchJson('/api/labels'),
])
This is faster than awaiting one request before starting the other.
Promise.all() rejects when one of the helper calls rejects. It does not cancel the other request. Use a shared controller and call abort() if the remaining requests must stop too.
See my guide to Promise.all(), allSettled(), race(), and any() when partial failure or fallback requests matter.
Be careful with retries
Retry a request only when another attempt is safe and useful.
A limited retry can make sense for a GET that fails because of a temporary network problem, 429, or 503. Respect the server’s Retry-After header when it sends one.
Do not blindly retry a POST that creates a payment, order, or email. The first request may have succeeded even if its response never reached you. Use an idempotency key or another server-side deduplication mechanism first.
My advice is to keep retry policy close to the operation. A single global retry rule will eventually repeat something that should run once.
Control the browser cache
Fetch uses the browser’s HTTP cache by default. Server headers such as Cache-Control, ETag, and Last-Modified should define the normal caching policy.
You can change how one request interacts with that cache:
const response = await fetch('/api/todos', {
cache: 'no-store',
})
no-store bypasses the cache and does not store the new response.
Be careful with no-cache. Its name does not mean “never use the cache.” It lets the browser reuse a stored response after validating it with the server.
The browser HTTP cache and the Service Worker Cache API are different systems.
Stream a large response
Methods such as json() and text() read the complete body before returning a value.
For a large or progressively generated response, read response.body. It is a ReadableStream:
const response = await fetch('/api/report')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
if (!response.body) {
throw new Error('Missing response body')
}
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader()
while (true) {
const { value, done } = await reader.read()
if (done) break
console.log(value)
}
Each value is a chunk, not necessarily a complete line or JSON object. Network boundaries do not preserve your application message boundaries.
Use streaming when early output matters or the body is too large to buffer comfortably. For small JSON responses, json() is clearer.
Read the Streams API tutorial when you need transforms, backpressure, or custom streams.
How I use Fetch in my projects
I use Fetch directly in most of my projects. I prefer small functions named after the operation instead of one giant HTTP abstraction.
Sitebase is a good example. Its browser embed submits public forms with FormData. The Cloudflare Worker calls services such as Resend with JSON. Its outbound webhook worker sends signed JSON with a 10-second abort signal.
Those requests have different rules. A public form keeps the returned HTML error so the visitor can retry. An email call treats every non-2xx response as a failure. A webhook retries 429 and server errors, but not a permanent 4xx rejection.
Fetch stays the same. The small function around it defines the timeout, body format, error handling, and retry policy for that one job.
A complete small API client
Here is the complete pattern I would start with for a JSON todo API:
async function fetchJson(url, options = {}) {
const headers = new Headers(options.headers)
headers.set('Accept', 'application/json')
const response = await fetch(url, {
...options,
headers,
})
if (!response.ok) {
const message = (await response.text()).slice(0, 200)
throw new Error(message || `HTTP ${response.status}`)
}
if (response.status === 204 || response.status === 205) return null
return response.json()
}
export function getTodos() {
return fetchJson('/api/todos')
}
export function createTodo(title) {
return fetchJson('/api/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title }),
})
}
export function deleteTodo(id) {
return fetchJson(`/api/todos/${id}`, {
method: 'DELETE',
})
}
It has one clear contract: successful responses contain JSON or return 204. Everything else becomes an error.
Add authentication, cancellation, retries, and caching only when that API needs them.
Debug Fetch requests
Open the browser DevTools Network panel and inspect the actual request.
Check:
- the final URL and query string
- the request method and headers
- the request body or form payload
- the response status and headers
- the raw response before parsing
- redirect and timing information
Then test a slow connection, an offline request, a 404, a 500, invalid JSON, and a timeout. Happy-path testing hides most Fetch mistakes.
When Fetch is not the right tool
Fetch is my default for ordinary HTTP requests, but it is not the answer to every network problem.
Use a native form submission when the page does not need a JavaScript-only experience. Use EventSource or WebSockets for a long-lived stream of server events. Browser upload progress still has better cross-browser support with XMLHttpRequest.
And use a service’s official SDK when it provides important signing, pagination, retries, or protocol rules you would otherwise have to rebuild.
The Fetch API deliberately stays low-level. Once you understand the request, response, and body lifecycle, that simplicity becomes its biggest advantage.
Related posts about platform: