JavaScript Fetch API

Make HTTP requests from the browser: GET, POST, send JSON, handle errors, upload files, add auth headers, and implement timeouts.

Intermediate 11 min read 10 examples

fetch() Basics

fetch(url) sends an HTTP GET request and returns a Promise that resolves to a Response object when the server responds.

JavaScript
// GET request - returns a Response Promise
fetch('https://api.example.com/users')
    .then(response => response.json()) // parse body as JSON
    .then(users    => console.log(users))
    .catch(err     => console.error('Network error:', err));

// With async/await (cleaner)
async function getUsers() {
    const response = await fetch('https://api.example.com/users');
    const users    = await response.json();
    return users;
}

// Pass URL options (query params)
const params = new URLSearchParams({ page: 1, limit: 20, sort: 'name' });
const response = await fetch('/api/users?' + params);
// URL: /api/users?page=1&limit=20&sort=name

// Full options object (GET is the default method)
const res = await fetch('/api/data', {
    method:  'GET',
    headers: { 'Accept': 'application/json' },
    cache:   'no-cache',    // 'default', 'no-store', 'reload', 'force-cache'
    mode:    'cors',        // 'cors', 'no-cors', 'same-origin'
    credentials: 'include', // 'omit', 'same-origin', 'include' (send cookies)
});

The Response Object

JavaScript
const response = await fetch('/api/user/1');

// Status
console.log(response.status);     // 200, 404, 500, etc.
console.log(response.statusText); // 'OK', 'Not Found', etc.
console.log(response.ok);         // true for 2xx status codes

// Headers
console.log(response.headers.get('Content-Type')); // 'application/json'
console.log(response.headers.get('X-Request-Id'));

// Reading the body (can only be read ONCE - it is a stream)
const json    = await response.json();       // parse as JSON
const text    = await response.text();       // read as string
const blob    = await response.blob();       // for images/files
const buffer  = await response.arrayBuffer(); // raw binary

// Clone before reading (if you need to read the body twice)
const clone = response.clone();
const body1 = await response.json();
const body2 = await clone.json(); // read the clone

// response.bodyUsed - true after body has been read
console.log(response.bodyUsed); // true after await response.json()

// Redirect handling (response.redirected tells you if it was followed)
console.log(response.url);         // final URL after redirects
console.log(response.redirected);  // true if request was redirected

POST with JSON

JavaScript
// POST with JSON body
async function createUser(userData) {
    const response = await fetch('/api/users', {
        method:  'POST',
        headers: {
            'Content-Type': 'application/json',
            // Must set Content-Type so server knows how to parse the body
        },
        body: JSON.stringify(userData), // object -> JSON string
    });

    if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message ?? 'Request failed');
    }

    return response.json(); // server usually returns the created resource
}

const newUser = await createUser({ name: 'Alice', email: 'alice@example.com' });
console.log('Created:', newUser.id);

// PUT - update an existing resource
async function updateUser(id, data) {
    const res = await fetch(`/api/users/${id}`, {
        method:  'PUT',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify(data),
    });
    return res.json();
}

// PATCH - partial update
async function patchUser(id, data) {
    const res = await fetch(`/api/users/${id}`, {
        method:  'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify(data),
    });
    return res.json();
}

// DELETE
async function deleteUser(id) {
    const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
    return res.ok;
}

Headers

JavaScript
// Plain object headers (simplest for one-off requests)
const response = await fetch('/api/data', {
    headers: {
        'Authorization': 'Bearer ' + getToken(),
        'Content-Type':  'application/json',
        'X-Client-ID':   'my-app-v2',
    }
});

// Headers class (more control)
const headers = new Headers();
headers.append('Authorization', 'Bearer ' + token);
headers.append('Content-Type', 'application/json');
headers.set('X-Custom', 'value');       // set replaces existing
headers.delete('X-Unwanted');
console.log(headers.has('Authorization')); // true
console.log(headers.get('Authorization')); // 'Bearer ...'
// Iterate all headers
for (const [name, value] of headers) {
    console.log(name, ':', value);
}

// Reusable fetch wrapper with default headers
function apiFetch(url, options = {}) {
    const token = localStorage.getItem('auth_token');
    return fetch(url, {
        ...options,
        headers: {
            'Content-Type':  'application/json',
            'Authorization': token ? 'Bearer ' + token : undefined,
            ...options.headers, // allow caller to add/override headers
        },
    });
}

// Usage
const res = await apiFetch('/api/profile');
const res2 = await apiFetch('/api/upload', {
    method: 'POST',
    headers: { 'Content-Type': 'multipart/form-data' }, // overrides default
});

Error Handling

fetch() only rejects on network failure. HTTP error status codes (4xx, 5xx) do NOT automatically reject - you must check response.ok.

JavaScript
// Common pattern: check response.ok before reading body
async function fetchJSON(url, options) {
    const response = await fetch(url, options);
    if (!response.ok) {
        // Try to read error message from server
        let message = `HTTP ${response.status}`;
        try {
            const err = await response.json();
            message = err.message ?? message;
        } catch {
            // Body wasn't JSON - use status text
            message = response.statusText;
        }
        throw new Error(message);
    }
    return response.json();
}

// Usage with try/catch
try {
    const user = await fetchJSON('/api/users/99');
    renderUser(user);
} catch (err) {
    if (err.message.startsWith('HTTP 404')) {
        showNotFound();
    } else if (err.message.startsWith('HTTP 401')) {
        redirectToLogin();
    } else {
        showGenericError(err.message);
    }
}

// Error types to handle:
// 1. Network error (no internet, DNS fail) - fetch() rejects with TypeError
// 2. CORS error - fetch() rejects with TypeError (no access to the response)
// 3. HTTP 4xx/5xx - must check response.ok manually
// 4. Invalid JSON body - response.json() rejects with SyntaxError
fetch() succeeds on HTTP 404 and 500

This is the most common fetch gotcha. A 404 Not Found or 500 Server Error response does not reject the Promise - the network request technically "succeeded". Always check if (!response.ok) before reading the body data.

File Upload with FormData

JavaScript
const fileInput = document.querySelector('input[type="file"]');

async function uploadFile(file) {
    const formData = new FormData();
    formData.append('file', file);
    formData.append('userId', '42');

    // Do NOT set Content-Type - browser sets it with correct multipart boundary
    const response = await fetch('/api/upload', {
        method: 'POST',
        body:   formData,
    });

    if (!response.ok) throw new Error('Upload failed');
    return response.json(); // { url: 'https://...' }
}

fileInput.addEventListener('change', async function() {
    const file = this.files[0];
    if (!file) return;

    // Client-side validation
    const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
    if (file.size > MAX_SIZE) {
        alert('File too large. Maximum 5 MB.');
        return;
    }
    const ALLOWED = ['image/jpeg', 'image/png', 'image/webp'];
    if (!ALLOWED.includes(file.type)) {
        alert('Only JPEG, PNG, and WebP images allowed.');
        return;
    }

    try {
        const result = await uploadFile(file);
        document.querySelector('#preview').src = result.url;
    } catch (err) {
        showError(err.message);
    }
});

Advanced Patterns

JavaScript
// Pattern 1: Timeout with AbortController
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    try {
        const res = await fetch(url, { ...options, signal: controller.signal });
        return res;
    } catch (err) {
        if (err.name === 'AbortError') throw new Error('Request timed out');
        throw err;
    } finally {
        clearTimeout(timer);
    }
}

// Pattern 2: Cancel a request (e.g., on component unmount or new search)
let searchController = null;

async function search(query) {
    searchController?.abort(); // cancel previous request
    searchController = new AbortController();
    try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
            signal: searchController.signal,
        });
        return res.json();
    } catch (err) {
        if (err.name === 'AbortError') return; // user typed more - ignore
        throw err;
    }
}

// Pattern 3: Retry on failure
async function fetchWithRetry(url, options, retries = 3, delay = 1000) {
    for (let attempt = 0; attempt < retries; attempt++) {
        try {
            const res = await fetch(url, options);
            if (res.ok) return res;
            if (res.status < 500) throw new Error('Client error: ' + res.status);
            // 5xx - retry
        } catch (err) {
            if (attempt === retries - 1) throw err;
        }
        await new Promise(r => setTimeout(r, delay * (attempt + 1))); // backoff
    }
}

Frequently Asked Questions

fetch() only rejects on network failures (no internet, DNS failure, CORS block). An HTTP 404, 500, or 401 response is still considered a "successful" fetch - the network worked. You must manually check response.ok (true for 200-299) or response.status and throw an error yourself. This is a common gotcha: if (!response.ok) throw new Error(response.status).

Both are methods on the Response object that return Promises. response.json() reads the body and parses it as JSON - throws if the body is not valid JSON. response.text() reads the body as a plain string. Other methods: response.blob() for binary data (images, files), response.arrayBuffer() for raw binary, response.formData() for form data. You can only read the body once - calling a second method throws an error.

Wrap fetch() in your own function that always includes the auth header. Store the token (from localStorage or a module-level variable) and merge it into the headers option. Example: const token = localStorage.getItem("token"); fetch(url, { headers: { Authorization: "Bearer " + token, ...options.headers } }). This is cleaner than using a global interceptor approach.

Use an AbortController. Pass its signal to the fetch options: fetch(url, { signal: controller.signal }). Call controller.abort() to cancel. The fetch Promise rejects with an AbortError. Check with err.name === "AbortError" to distinguish cancellation from real errors. This is the standard way to implement request timeouts and cancel stale requests in search inputs.