The Fetch API

โ–ถ Try It Yourself

The Fetch API is the modern, Promise-based way to make HTTP requests from the browser โ€” replacing the verbose, callback-based XMLHttpRequest. It is built into all modern browsers and is the foundation of every web application that communicates with a server: loading data, submitting forms, calling REST APIs, and uploading files. In this lesson you will master the fetch request lifecycle, all configuration options, reading different response types, sending JSON and FormData, handling errors correctly, and adding request cancellation with AbortController.

fetch Request and Response

Property / Method Description Example
fetch(url, options) Returns Promise<Response> fetch('/api/users')
response.ok Boolean โ€” status 200-299 if (!res.ok) throw new Error(...)
response.status HTTP status code number 404, 200, 500
response.statusText Status message string 'Not Found', 'OK'
response.json() Promise<any> โ€” parse body as JSON For JSON APIs
response.text() Promise<string> โ€” body as text For HTML or plain text responses
response.blob() Promise<Blob> โ€” body as binary For images, files, downloads
response.formData() Promise<FormData> For multipart responses
response.headers.get(name) Header value or null res.headers.get('content-type')
response.clone() Clone response โ€” body can only be read once Read body multiple times

fetch Options

Option Type Description
method string 'GET' (default), 'POST', 'PUT', 'PATCH', 'DELETE'
headers object / Headers { 'Content-Type': 'application/json', Authorization: ... }
body string / FormData / Blob Request body โ€” not for GET/HEAD
signal AbortSignal Cancel the request
credentials string 'same-origin' (default), 'include' (send cookies cross-origin), 'omit'
cache string 'default', 'no-store', 'reload', 'force-cache'
mode string 'cors' (default), 'no-cors', 'same-origin'

Critical: fetch Does Not Reject on HTTP Errors

Scenario fetch behaviour response.ok
200 OK Resolves true
404 Not Found Resolves โ€” no rejection! false
500 Server Error Resolves โ€” no rejection! false
Network failure / CORS error Rejects โ€” TypeError N/A
AbortController.abort() Rejects โ€” AbortError N/A
Note: This is the most important thing to know about fetch: it only rejects on network errors (no connection, CORS block, DNS failure). HTTP error codes like 404 or 500 resolve the Promise โ€” you must check response.ok or response.status manually. Failing to do this means your code silently receives error responses as if they were successful ones.
Tip: Build a thin wrapper around fetch that throws on HTTP errors automatically, adds base URL, attaches auth headers, and parses JSON: async function api(path, opts) { const res = await fetch(baseURL + path, { ...defaults, ...opts }); if (!res.ok) throw new Error(...); return res.json(); }. Centralising these concerns keeps every individual call clean and consistent.
Warning: A Response body can only be consumed once. Calling response.json() after already calling response.text() on the same response throws an error. If you need to read the body in multiple places โ€” for logging and parsing, for example โ€” use response.clone() first to create an independent copy, then read each clone separately.

Basic Example

// โ”€โ”€ GET request โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function getUsers() {
    const response = await fetch('/api/users');

    // CRITICAL: check response.ok โ€” fetch does NOT reject on 4xx/5xx
    if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    return response.json();   // parse body as JSON
}

// โ”€โ”€ POST โ€” send JSON โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function createUser(userData) {
    const response = await fetch('/api/users', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify(userData),
    });

    if (!response.ok) {
        const error = await response.json().catch(() => ({}));
        throw new Error(error.message ?? `HTTP ${response.status}`);
    }

    return response.json();
}

// โ”€โ”€ PUT / PATCH / DELETE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function updateUser(id, changes) {
    const res = await fetch(`/api/users/${id}`, {
        method:  'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify(changes),
    });
    if (!res.ok) throw new Error(`Update failed: ${res.status}`);
    return res.json();
}

async function deleteUser(id) {
    const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
    if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
    return true;
}

// โ”€โ”€ File / FormData upload โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function uploadAvatar(userId, file) {
    const formData = new FormData();
    formData.append('avatar', file);
    formData.append('userId', userId);

    // DO NOT set Content-Type โ€” browser sets it with boundary automatically
    const res = await fetch(`/api/users/${userId}/avatar`, {
        method: 'POST',
        body:   formData,
    });
    if (!res.ok) throw new Error('Upload failed');
    return res.json();
}

// โ”€โ”€ Download a file (blob) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function downloadReport(id) {
    const res = await fetch(`/api/reports/${id}/download`);
    if (!res.ok) throw new Error('Download failed');

    const blob     = await res.blob();
    const url      = URL.createObjectURL(blob);
    const filename = res.headers.get('content-disposition')
        ?.match(/filename="(.+)"/)?.[1] ?? 'report.pdf';

    const a    = document.createElement('a');
    a.href     = url;
    a.download = filename;
    a.click();
    URL.revokeObjectURL(url);
}

// โ”€โ”€ AbortController โ€” cancellation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function searchWithCancel(query) {
    const controller = new AbortController();

    // Auto-cancel after 5 seconds
    const timeoutId = setTimeout(() => controller.abort(), 5000);

    try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
            signal: controller.signal,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return await res.json();
    } catch (err) {
        if (err.name === 'AbortError') {
            console.log('Request cancelled');
            return null;
        }
        throw err;
    } finally {
        clearTimeout(timeoutId);
    }
}

// โ”€โ”€ Live search โ€” cancel previous request when new one starts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
let currentController = null;

searchInput.addEventListener('input', async (e) => {
    const query = e.target.value.trim();
    if (!query) { results.innerHTML = ''; return; }

    // Cancel previous in-flight request
    currentController?.abort();
    currentController = new AbortController();

    try {
        const data = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
            signal: currentController.signal,
        }).then(r => r.json());

        renderResults(data);
    } catch (err) {
        if (err.name !== 'AbortError') showError(err);
    }
});

// โ”€โ”€ Reading response headers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const res = await fetch('/api/data');
console.log(res.headers.get('x-total-count'));   // pagination total
console.log(res.headers.get('x-rate-limit-remaining'));

How It Works

Step 1 โ€” fetch Returns a Promise of a Response Object

The Response object contains the HTTP status, headers, and a readable stream for the body. The body is NOT automatically parsed โ€” you choose how to read it by calling .json(), .text(), .blob(), or .arrayBuffer(). Each returns a Promise that resolves when the body is fully downloaded and parsed.

Step 2 โ€” Always Check response.ok

fetch resolves its Promise when a response arrives โ€” even a 404 or 500. response.ok is true for status codes 200-299 and false for everything else. Checking this and throwing if not OK is the most important fetch pattern โ€” without it, your application silently treats error responses as successful data.

Step 3 โ€” JSON Stringify for POST Bodies

When sending JSON, you must both stringify the body with JSON.stringify(data) and set the Content-Type: application/json header. Without the header, the server may not know how to parse the body. Without JSON.stringify, the body will be [object Object] โ€” a string representation of the object.

Step 4 โ€” AbortController Enables Cancellation

An AbortController creates an AbortSignal that can be passed to fetch. Calling controller.abort() cancels the request โ€” the fetch Promise rejects with an AbortError. This is essential for live search (cancel the previous request when a new query arrives) and timeout wrappers (abort if no response within N seconds).

Step 5 โ€” FormData Upload Sets Content-Type Automatically

When you pass a FormData object as the body, the browser automatically sets Content-Type: multipart/form-data with the correct boundary string. If you manually set Content-Type, the browser cannot add the boundary โ€” and the server cannot parse the multipart body. Always omit the Content-Type header for FormData requests.

Real-World Example: REST API Wrapper

// rest-api.js

async function apiFetch(path, { method = 'GET', body, headers = {}, signal } = {}) {
    const token = localStorage.getItem('token');

    const options = {
        method,
        signal,
        headers: {
            'Content-Type': 'application/json',
            ...(token ? { Authorization: `Bearer ${token}` } : {}),
            ...headers,
        },
        ...(body ? { body: JSON.stringify(body) } : {}),
    };

    const response = await fetch(`/api${path}`, options);

    // Parse body regardless of status (error details may be in body)
    let data = null;
    const ct = response.headers.get('content-type') ?? '';
    if (ct.includes('application/json') && response.status !== 204) {
        data = await response.json().catch(() => null);
    }

    if (!response.ok) {
        const err      = new Error(data?.message ?? `HTTP ${response.status}`);
        err.status     = response.status;
        err.data       = data;

        // Auto-redirect on 401
        if (response.status === 401) {
            window.location.href = '/login';
        }
        throw err;
    }

    return data;
}

// Clean calling code โ€” no boilerplate
const users  = await apiFetch('/users');
const newUser = await apiFetch('/users', { method: 'POST', body: { name: 'Alice' } });
const updated = await apiFetch(`/users/${id}`, { method: 'PATCH', body: { role: 'admin' } });
await apiFetch(`/users/${id}`, { method: 'DELETE' });

Common Mistakes

Mistake 1 โ€” Not checking response.ok

โŒ Wrong โ€” 404/500 treated as success:

const data = await fetch('/api/users/999').then(r => r.json());
// If 404: data = { error: 'Not found' } โ€” but code continues as if success

โœ… Correct โ€” always check ok:

const res = await fetch('/api/users/999');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();

Mistake 2 โ€” Setting Content-Type on FormData

โŒ Wrong โ€” breaks multipart boundary:

fetch('/upload', {
    method: 'POST',
    headers: { 'Content-Type': 'multipart/form-data' },  // โŒ no boundary!
    body: formData,
});

โœ… Correct โ€” omit Content-Type for FormData:

fetch('/upload', { method: 'POST', body: formData });  // browser sets it correctly

Mistake 3 โ€” Reading response body twice

โŒ Wrong โ€” body can only be consumed once:

const text = await response.text();
const json = await response.json();   // TypeError: body already used

โœ… Correct โ€” clone if you need to read twice:

const clone = response.clone();
const text  = await response.text();   // log raw response
const json  = await clone.json();      // also parse as JSON

▶ Try It Yourself

Quick Reference

Task Code
GET JSON const res = await fetch(url); if(!res.ok) throw...; return res.json();
POST JSON fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) })
Upload file fetch(url, { method:'POST', body: new FormData(form) })
Cancel request const c = new AbortController(); fetch(url, { signal: c.signal }); c.abort();
Check error if (!response.ok) throw new Error(...)
Read text await response.text()
Read binary await response.blob()
Read header response.headers.get('x-total-count')

🧠 Test Yourself

A fetch request to /api/users/999 returns a 404 response. What happens?





โ–ถ Try It Yourself