JavaScript Essentials

Fetch API & HTTP Requests

45 min Lesson 42 of 60

Introduction to the Fetch API

The Fetch API is a modern, built-in JavaScript interface for making HTTP requests from the browser. It was introduced to replace the older XMLHttpRequest (XHR) object, providing a cleaner, Promise-based approach to network communication. Fetch is supported in all modern browsers and is the standard way to interact with REST APIs, load data from servers, submit forms programmatically, and download or upload files in web applications.

Unlike XMLHttpRequest, which used callback-based event handling and had a confusing API, the Fetch API uses Promises, making it naturally compatible with async/await syntax. Every call to fetch() returns a Promise that resolves to a Response object, which provides methods to access the response body in various formats like JSON, text, or binary data. Throughout this lesson, you will learn every aspect of the Fetch API, from simple GET requests to complex file uploads, error handling, request cancellation, and real-world API interaction patterns.

The fetch() Syntax

The fetch() function takes two arguments: the URL to request and an optional configuration object. When called with just a URL, it performs a GET request by default. The configuration object lets you specify the HTTP method, headers, body, mode, credentials, and other options.

Example: Basic fetch() Syntax

// Simplest form: GET request with just a URL
fetch('https://api.example.com/data');

// Full form with configuration object
fetch('https://api.example.com/data', {
    method: 'GET',             // HTTP method
    headers: {                  // Request headers
        'Accept': 'application/json',
        'Authorization': 'Bearer token123'
    },
    mode: 'cors',              // CORS mode
    credentials: 'same-origin', // Cookie handling
    cache: 'default',          // Cache mode
    redirect: 'follow',        // Redirect handling
    signal: null               // AbortController signal
});
Note: The fetch() function is available globally in the browser -- you do not need to import or install anything. In Node.js, fetch became available natively starting from version 18. For older Node.js versions, you would need a polyfill like node-fetch.

The Response Object

When fetch() resolves, it returns a Response object. This object contains all the information about the server's response, including the HTTP status code, headers, and methods to read the response body. Understanding the Response object is essential for properly handling API responses.

Example: Exploring the Response Object

async function exploreResponse() {
    const response = await fetch('https://api.example.com/users/1');

    // Response properties
    console.log('Status:', response.status);        // 200, 404, 500, etc.
    console.log('Status Text:', response.statusText); // "OK", "Not Found", etc.
    console.log('OK:', response.ok);                // true if status is 200-299
    console.log('URL:', response.url);              // The final URL after redirects
    console.log('Redirected:', response.redirected); // true if redirected
    console.log('Type:', response.type);            // "basic", "cors", "opaque"

    // Reading response headers
    console.log('Content-Type:', response.headers.get('Content-Type'));
    console.log('Cache-Control:', response.headers.get('Cache-Control'));

    // Iterating over all headers
    for (const [key, value] of response.headers) {
        console.log(key + ': ' + value);
    }

    // Check if a header exists
    console.log('Has ETag:', response.headers.has('ETag'));
}

The response.ok property is extremely important. It is true when the HTTP status code is in the range 200-299 (success codes) and false for everything else (client errors like 404, server errors like 500). This is the primary way to check if a request was successful before reading the response body.

Response Body Methods

The Response object provides several methods to read the body of the response. Each method returns a Promise that resolves with the body content in a specific format. You can only read the body once -- after calling one of these methods, the body stream is consumed and cannot be read again unless you clone the response first.

Example: Different Response Body Methods

// 1. response.json() -- Parse as JSON (most common for APIs)
async function getJsonData() {
    const response = await fetch('/api/users');
    const data = await response.json();
    console.log(data); // JavaScript object/array
}

// 2. response.text() -- Read as plain text
async function getTextData() {
    const response = await fetch('/api/readme');
    const text = await response.text();
    console.log(text); // Plain text string
}

// 3. response.blob() -- Read as binary Blob (for files, images)
async function getImageBlob() {
    const response = await fetch('/images/photo.jpg');
    const blob = await response.blob();
    const imageUrl = URL.createObjectURL(blob);
    document.getElementById('preview').src = imageUrl;
}

// 4. response.arrayBuffer() -- Read as raw binary data
async function getBinaryData() {
    const response = await fetch('/api/download/file.pdf');
    const buffer = await response.arrayBuffer();
    console.log('Received ' + buffer.byteLength + ' bytes');
}

// 5. response.formData() -- Read as FormData object
async function getFormData() {
    const response = await fetch('/api/form-response');
    const formData = await response.formData();
    console.log(formData.get('username'));
}

// Cloning a response to read the body multiple times
async function readBodyTwice() {
    const response = await fetch('/api/users');
    const clone = response.clone();

    const text = await response.text();    // Read original as text
    const json = await clone.json();       // Read clone as JSON
    console.log('Text length:', text.length);
    console.log('JSON entries:', json.length);
}
Common Mistake: Trying to read the response body more than once. Calling response.json() and then response.text() on the same Response object will throw an error because the body stream has already been consumed. If you need to read the body in multiple formats, use response.clone() before reading.

GET Requests

GET requests are the most common type of HTTP request. They are used to retrieve data from a server. By default, fetch() makes a GET request, so you only need to provide the URL. GET requests should never modify data on the server -- they are meant to be safe and idempotent.

Example: GET Requests with Query Parameters

// Simple GET request
async function getUsers() {
    const response = await fetch('/api/users');
    const users = await response.json();
    return users;
}

// GET with query parameters using string concatenation
async function searchUsers(query, page = 1) {
    const response = await fetch(
        '/api/users?search=' + encodeURIComponent(query) + '&page=' + page
    );
    return response.json();
}

// GET with query parameters using URLSearchParams (recommended)
async function searchUsersClean(query, page = 1, limit = 20) {
    const params = new URLSearchParams({
        search: query,
        page: page,
        limit: limit,
        sort: 'name'
    });

    const response = await fetch('/api/users?' + params.toString());
    return response.json();
}

// GET with custom headers
async function getUsersAuthenticated(token) {
    const response = await fetch('/api/users', {
        headers: {
            'Authorization': 'Bearer ' + token,
            'Accept': 'application/json'
        }
    });

    if (!response.ok) {
        throw new Error('Failed to fetch users: ' + response.status);
    }

    return response.json();
}
Pro Tip: Always use URLSearchParams to build query strings instead of manual string concatenation. It automatically handles URL encoding of special characters, preventing broken URLs and potential security issues. You can also pass it directly to the URL constructor for an even cleaner approach.

POST Requests

POST requests are used to send data to the server, typically to create new resources. With the Fetch API, you specify method: 'POST' in the configuration object and include the data in the body property. The most common format for sending data is JSON, which requires setting the Content-Type header to application/json.

Example: POST Request with JSON Body

async function createUser(userData) {
    const response = await fetch('/api/users', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Accept': 'application/json'
        },
        body: JSON.stringify({
            name: userData.name,
            email: userData.email,
            role: userData.role || 'user'
        })
    });

    if (!response.ok) {
        const errorData = await response.json();
        throw new Error(errorData.message || 'Failed to create user');
    }

    const newUser = await response.json();
    console.log('User created with ID:', newUser.id);
    return newUser;
}

// Usage
const user = await createUser({
    name: 'Ahmad Hassan',
    email: 'ahmad@example.com',
    role: 'admin'
});

Example: POST Request with Form Data

// Sending form data (useful for file uploads)
async function submitForm(formElement) {
    const formData = new FormData(formElement);

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

    return response.json();
}

// Building FormData manually
async function uploadProfile(name, email, avatarFile) {
    const formData = new FormData();
    formData.append('name', name);
    formData.append('email', email);
    formData.append('avatar', avatarFile, avatarFile.name);

    const response = await fetch('/api/profile', {
        method: 'POST',
        body: formData
    });

    return response.json();
}
Note: When sending FormData, do not manually set the Content-Type header. The browser automatically sets it to multipart/form-data with the correct boundary string. Setting the header manually will break the request because the boundary will not match.

PUT, PATCH, and DELETE Requests

PUT is used to replace an entire resource, PATCH is used to partially update a resource, and DELETE is used to remove a resource. These methods follow the same pattern as POST but with different HTTP methods specified.

Example: PUT, PATCH, and DELETE Requests

// PUT -- Replace entire resource
async function replaceUser(id, userData) {
    const response = await fetch('/api/users/' + id, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(userData) // Must include ALL fields
    });
    return response.json();
}

// PATCH -- Partially update a resource
async function updateUserEmail(id, newEmail) {
    const response = await fetch('/api/users/' + id, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email: newEmail }) // Only the changed field
    });
    return response.json();
}

// DELETE -- Remove a resource
async function deleteUser(id) {
    const response = await fetch('/api/users/' + id, {
        method: 'DELETE',
        headers: {
            'Authorization': 'Bearer ' + getToken()
        }
    });

    if (!response.ok) {
        throw new Error('Failed to delete user: ' + response.status);
    }

    // Some APIs return 204 No Content for successful deletes
    if (response.status === 204) {
        return true;
    }

    return response.json();
}

Sending JSON Data

JSON (JavaScript Object Notation) is the most common data format used in modern web APIs. When sending JSON data with fetch, you need to do two things: set the Content-Type header to application/json and convert your JavaScript object to a JSON string using JSON.stringify().

Example: Sending Various Types of JSON Data

// Sending a simple object
async function createPost(title, content, tags) {
    const response = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title, content, tags })
    });
    return response.json();
}

// Sending nested objects
async function createOrder(items, shippingAddress) {
    const orderData = {
        items: items.map(item => ({
            productId: item.id,
            quantity: item.qty,
            price: item.price
        })),
        shipping: {
            street: shippingAddress.street,
            city: shippingAddress.city,
            country: shippingAddress.country,
            postalCode: shippingAddress.zip
        },
        metadata: {
            source: 'web',
            timestamp: new Date().toISOString()
        }
    };

    const response = await fetch('/api/orders', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Accept': 'application/json'
        },
        body: JSON.stringify(orderData)
    });

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

    return response.json();
}

// Sending an array as the body
async function batchUpdateStatuses(updates) {
    // updates = [{id: 1, status: 'active'}, {id: 2, status: 'inactive'}]
    const response = await fetch('/api/users/batch-update', {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates)
    });
    return response.json();
}

Sending FormData

FormData is used when you need to send binary files (images, documents, videos) or when you want to replicate a traditional HTML form submission. The FormData API lets you build key-value pairs that are sent as multipart/form-data, which is the encoding type required for file uploads.

Example: File Upload with FormData

// Single file upload
async function uploadFile(file) {
    const formData = new FormData();
    formData.append('file', file);
    formData.append('description', 'Uploaded via web app');

    const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData
        // No Content-Type header! Browser sets it automatically.
    });

    if (!response.ok) {
        throw new Error('Upload failed: ' + response.status);
    }

    return response.json();
}

// Multiple file upload
async function uploadMultipleFiles(files) {
    const formData = new FormData();

    for (let i = 0; i < files.length; i++) {
        formData.append('files[]', files[i], files[i].name);
    }

    const response = await fetch('/api/upload/batch', {
        method: 'POST',
        body: formData
    });

    return response.json();
}

// From an HTML form element
async function submitContactForm() {
    const form = document.getElementById('contactForm');
    const formData = new FormData(form);

    // You can add extra fields programmatically
    formData.append('submittedAt', new Date().toISOString());
    formData.append('userAgent', navigator.userAgent);

    const response = await fetch('/api/contact', {
        method: 'POST',
        body: formData
    });

    if (response.ok) {
        form.reset();
        alert('Message sent successfully!');
    }
}

Handling Errors: Network Errors vs HTTP Errors

One of the most important things to understand about the Fetch API is how it handles errors. The fetch() Promise only rejects on network errors -- when the browser cannot reach the server at all (no internet connection, DNS failure, CORS blocked, server down). It does not reject on HTTP error status codes like 404 (Not Found) or 500 (Internal Server Error). You must check response.ok or response.status manually to detect HTTP errors.

Example: Proper Error Handling

async function fetchData(url) {
    try {
        const response = await fetch(url);

        // Check for HTTP errors (fetch does NOT throw for 4xx/5xx)
        if (!response.ok) {
            // Attempt to read the error message from the response body
            let errorMessage = 'HTTP Error: ' + response.status;
            try {
                const errorBody = await response.json();
                errorMessage = errorBody.message || errorMessage;
            } catch (parseError) {
                // Response body was not JSON, use the default message
            }
            throw new Error(errorMessage);
        }

        return await response.json();

    } catch (error) {
        // This catches BOTH network errors and our thrown HTTP errors
        if (error instanceof TypeError) {
            // Network error: no connection, DNS failure, CORS blocked
            console.error('Network error:', error.message);
            throw new Error('Could not connect to the server. Check your internet connection.');
        }

        // Re-throw HTTP errors and other errors
        throw error;
    }
}

// A reusable wrapper that standardizes error handling
async function apiFetch(url, options = {}) {
    const defaultOptions = {
        headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/json',
            ...options.headers
        },
        ...options
    };

    try {
        const response = await fetch(url, defaultOptions);

        if (!response.ok) {
            const error = new Error('API Error');
            error.status = response.status;
            error.statusText = response.statusText;

            try {
                error.data = await response.json();
            } catch (e) {
                error.data = null;
            }

            throw error;
        }

        // Handle 204 No Content
        if (response.status === 204) {
            return null;
        }

        return response.json();

    } catch (error) {
        if (!error.status) {
            // This is a network error, not an HTTP error
            error.status = 0;
            error.message = 'Network Error: ' + error.message;
        }
        throw error;
    }
}
Common Mistake: Assuming that fetch() will throw an error for a 404 or 500 response. It will not. The Promise from fetch() only rejects for network failures. A 404 Not Found or 500 Internal Server Error is still a successful HTTP response from the browser's perspective. Always check response.ok before processing the response body.

AbortController for Request Cancellation

The AbortController API lets you cancel ongoing fetch requests. This is essential for scenarios like search-as-you-type (where you want to cancel previous searches when the user types new characters), navigating away from a page while data is still loading, or implementing request timeouts.

Example: Cancelling a Fetch Request

// Basic abort example
const controller = new AbortController();
const signal = controller.signal;

fetch('/api/large-dataset', { signal })
    .then(response => response.json())
    .then(data => console.log('Data received:', data))
    .catch(error => {
        if (error.name === 'AbortError') {
            console.log('Request was cancelled');
        } else {
            console.error('Fetch error:', error);
        }
    });

// Cancel the request after 3 seconds
setTimeout(() => controller.abort(), 3000);

// Implementing a timeout wrapper
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

    try {
        const response = await fetch(url, {
            ...options,
            signal: controller.signal
        });
        return response;
    } catch (error) {
        if (error.name === 'AbortError') {
            throw new Error('Request timed out after ' + timeoutMs + 'ms');
        }
        throw error;
    } finally {
        clearTimeout(timeoutId);
    }
}

Example: Search-As-You-Type with Abort

class SearchController {
    constructor() {
        this.currentController = null;
    }

    async search(query) {
        // Cancel any previous search request
        if (this.currentController) {
            this.currentController.abort();
        }

        // Create a new controller for this search
        this.currentController = new AbortController();

        try {
            const params = new URLSearchParams({ q: query, limit: 10 });
            const response = await fetch('/api/search?' + params, {
                signal: this.currentController.signal
            });

            if (!response.ok) {
                throw new Error('Search failed');
            }

            const results = await response.json();
            return results;

        } catch (error) {
            if (error.name === 'AbortError') {
                // This is expected when a new search cancels the old one
                console.log('Previous search cancelled');
                return null;
            }
            throw error;
        }
    }
}

// Usage with a search input
const searcher = new SearchController();
const searchInput = document.getElementById('search');

searchInput.addEventListener('input', async (event) => {
    const query = event.target.value.trim();
    if (query.length < 2) return;

    const results = await searcher.search(query);
    if (results) {
        displayResults(results);
    }
});

Fetch with Async/Await

Combining fetch with async/await creates clean, readable code for complex API interactions. Here is a comprehensive pattern that puts together everything we have learned so far.

Example: Complete API Service Class

class ApiService {
    constructor(baseUrl, token = null) {
        this.baseUrl = baseUrl;
        this.token = token;
    }

    getHeaders(customHeaders = {}) {
        const headers = {
            'Accept': 'application/json',
            'Content-Type': 'application/json',
            ...customHeaders
        };

        if (this.token) {
            headers['Authorization'] = 'Bearer ' + this.token;
        }

        return headers;
    }

    async request(endpoint, options = {}) {
        const url = this.baseUrl + endpoint;
        const config = {
            headers: this.getHeaders(options.headers),
            ...options
        };

        // Remove Content-Type for FormData (browser sets it)
        if (options.body instanceof FormData) {
            delete config.headers['Content-Type'];
        }

        const response = await fetch(url, config);

        if (!response.ok) {
            const error = new Error('API request failed');
            error.status = response.status;
            try {
                error.data = await response.json();
            } catch (e) {
                error.data = { message: response.statusText };
            }
            throw error;
        }

        if (response.status === 204) return null;
        return response.json();
    }

    async get(endpoint, params = {}) {
        const query = new URLSearchParams(params).toString();
        const url = query ? endpoint + '?' + query : endpoint;
        return this.request(url);
    }

    async post(endpoint, data) {
        return this.request(endpoint, {
            method: 'POST',
            body: data instanceof FormData ? data : JSON.stringify(data)
        });
    }

    async put(endpoint, data) {
        return this.request(endpoint, {
            method: 'PUT',
            body: JSON.stringify(data)
        });
    }

    async patch(endpoint, data) {
        return this.request(endpoint, {
            method: 'PATCH',
            body: JSON.stringify(data)
        });
    }

    async delete(endpoint) {
        return this.request(endpoint, { method: 'DELETE' });
    }
}

// Usage
const api = new ApiService('https://api.example.com', 'my-auth-token');

async function main() {
    // GET all users
    const users = await api.get('/users', { page: 1, limit: 20 });

    // POST a new user
    const newUser = await api.post('/users', {
        name: 'Fatima',
        email: 'fatima@example.com'
    });

    // PATCH update
    await api.patch('/users/' + newUser.id, { role: 'editor' });

    // DELETE
    await api.delete('/users/' + newUser.id);
}

Request Headers

HTTP headers let you send additional information with your request. They control authentication, content format, caching, and more. Here are the most commonly used request headers in web development.

Example: Common Request Headers

async function demonstrateHeaders() {
    const response = await fetch('/api/data', {
        headers: {
            // Authentication -- most common patterns
            'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...',
            // or for Basic auth:
            // 'Authorization': 'Basic ' + btoa('username:password'),

            // Content negotiation
            'Accept': 'application/json',         // What format you want back
            'Content-Type': 'application/json',    // What format you are sending
            'Accept-Language': 'en-US,en;q=0.9',   // Preferred language

            // Custom headers (often prefixed with X-)
            'X-Request-ID': 'req-' + Date.now(),
            'X-Client-Version': '2.1.0',

            // Caching
            'Cache-Control': 'no-cache',
            'If-None-Match': 'W/"abc123"',         // ETag for conditional requests

            // Security
            'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
        }
    });

    return response.json();
}

// Using the Headers constructor
async function usingHeadersObject() {
    const headers = new Headers();
    headers.append('Authorization', 'Bearer token');
    headers.append('Accept', 'application/json');

    // You can also set, delete, and check headers
    headers.set('Accept', 'text/html');    // Replace existing
    headers.delete('Accept');              // Remove header
    console.log(headers.has('Authorization')); // true

    const response = await fetch('/api/data', { headers });
    return response.json();
}
Pro Tip: When working with APIs that require CSRF protection (like Laravel), include the CSRF token from your page's meta tag. Laravel expects the token in the X-CSRF-TOKEN header or X-XSRF-TOKEN header. You can read it from the cookie or a meta tag that Laravel includes in the page layout.

CORS Basics

CORS (Cross-Origin Resource Sharing) is a security mechanism that controls which websites can make requests to a given server. When your JavaScript code on https://mysite.com tries to fetch data from https://api.other.com, the browser enforces CORS rules. The server must include specific headers in its response to allow cross-origin requests.

Example: CORS and Fetch Mode Options

// Default behavior: cors mode
// Browser sends a preflight OPTIONS request for non-simple requests
async function crossOriginRequest() {
    const response = await fetch('https://api.external.com/data', {
        mode: 'cors',         // Default -- enforces CORS
        credentials: 'include' // Send cookies with cross-origin requests
    });
    return response.json();
}

// Same-origin request (default for same-domain)
async function sameOriginRequest() {
    const response = await fetch('/api/data', {
        mode: 'same-origin'   // Fail if not same origin
    });
    return response.json();
}

// No-cors mode: limited but useful for certain cases
// Can only use HEAD, GET, or POST with limited headers
// Response is "opaque" -- you cannot read the body
async function noCorsRequest() {
    const response = await fetch('https://external.com/resource', {
        mode: 'no-cors'
    });
    // response.type will be "opaque"
    // You cannot read response.json() or response.text()
    console.log('Response type:', response.type);
}

// Handling CORS errors gracefully
async function safeCrossOrigin(url) {
    try {
        const response = await fetch(url);
        return await response.json();
    } catch (error) {
        if (error instanceof TypeError &&
            error.message.includes('Failed to fetch')) {
            console.error(
                'CORS error or network failure. ' +
                'The server at ' + url + ' may not allow ' +
                'requests from this origin.'
            );
        }
        throw error;
    }
}
Note: CORS is enforced by the browser, not by the server. The server sets CORS headers (like Access-Control-Allow-Origin) in its response, and the browser decides whether to allow JavaScript to access the response. If you control the server, you can configure it to allow requests from your frontend domain. CORS errors do not occur in server-to-server requests because CORS is purely a browser security feature.

Real-World API Interaction Examples

Let us look at complete, production-quality examples that demonstrate common real-world API interactions.

Example 1: Paginated Data Loading

Example: Loading Paginated Data

class PaginatedList {
    constructor(baseUrl) {
        this.baseUrl = baseUrl;
        this.items = [];
        this.currentPage = 0;
        this.totalPages = 1;
        this.loading = false;
        this.hasMore = true;
    }

    async loadNextPage() {
        if (this.loading || !this.hasMore) return;

        this.loading = true;
        const nextPage = this.currentPage + 1;

        try {
            const params = new URLSearchParams({
                page: nextPage,
                per_page: 20
            });

            const response = await fetch(this.baseUrl + '?' + params);
            if (!response.ok) throw new Error('Failed to load page ' + nextPage);

            const data = await response.json();
            this.items = this.items.concat(data.items);
            this.currentPage = data.currentPage;
            this.totalPages = data.totalPages;
            this.hasMore = this.currentPage < this.totalPages;

            return data.items;

        } catch (error) {
            console.error('Pagination error:', error.message);
            throw error;
        } finally {
            this.loading = false;
        }
    }

    async loadAll() {
        while (this.hasMore) {
            await this.loadNextPage();
        }
        return this.items;
    }
}

// Usage with infinite scroll
const productList = new PaginatedList('/api/products');

window.addEventListener('scroll', async () => {
    const nearBottom = window.innerHeight + window.scrollY
        >= document.body.offsetHeight - 500;

    if (nearBottom && productList.hasMore && !productList.loading) {
        const newItems = await productList.loadNextPage();
        if (newItems) {
            renderProducts(newItems);
        }
    }
});

Example 2: File Upload with Progress

Example: File Upload with Progress Tracking

// Note: fetch() does not natively support upload progress.
// For upload progress, use XMLHttpRequest or a library.
// However, fetch works great for downloads and basic uploads.

async function uploadWithFetch(file, onProgress) {
    const formData = new FormData();
    formData.append('file', file);

    // For simple upload (no progress tracking)
    const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData
    });

    if (!response.ok) {
        throw new Error('Upload failed with status ' + response.status);
    }

    return response.json();
}

// Download with progress using response.body stream
async function downloadWithProgress(url, onProgress) {
    const response = await fetch(url);
    if (!response.ok) throw new Error('Download failed');

    const contentLength = parseInt(response.headers.get('Content-Length') || '0');
    const reader = response.body.getReader();
    const chunks = [];
    let receivedLength = 0;

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        chunks.push(value);
        receivedLength += value.length;

        if (contentLength > 0 && onProgress) {
            const percent = Math.round((receivedLength / contentLength) * 100);
            onProgress(percent, receivedLength, contentLength);
        }
    }

    // Combine chunks into a single array
    const allChunks = new Uint8Array(receivedLength);
    let position = 0;
    for (const chunk of chunks) {
        allChunks.set(chunk, position);
        position += chunk.length;
    }

    return allChunks;
}

// Usage
downloadWithProgress('/api/files/large-report.pdf', (percent, received, total) => {
    console.log('Download progress: ' + percent + '%');
    document.getElementById('progress').style.width = percent + '%';
}).then(data => {
    const blob = new Blob([data], { type: 'application/pdf' });
    const url = URL.createObjectURL(blob);
    window.open(url);
});

Example 3: Complete CRUD Application

Example: Task Management CRUD

class TaskAPI {
    constructor() {
        this.baseUrl = '/api/tasks';
        this.token = localStorage.getItem('authToken');
    }

    getHeaders() {
        return {
            'Content-Type': 'application/json',
            'Accept': 'application/json',
            'Authorization': 'Bearer ' + this.token
        };
    }

    // CREATE
    async createTask(title, description, dueDate) {
        const response = await fetch(this.baseUrl, {
            method: 'POST',
            headers: this.getHeaders(),
            body: JSON.stringify({
                title: title,
                description: description,
                due_date: dueDate,
                status: 'pending'
            })
        });

        if (!response.ok) {
            const error = await response.json();
            throw new Error(error.message || 'Failed to create task');
        }

        return response.json();
    }

    // READ (list)
    async getTasks(filters = {}) {
        const params = new URLSearchParams();
        if (filters.status) params.append('status', filters.status);
        if (filters.sort) params.append('sort', filters.sort);
        if (filters.page) params.append('page', filters.page);

        const url = this.baseUrl + (params.toString() ? '?' + params : '');
        const response = await fetch(url, {
            headers: this.getHeaders()
        });

        if (!response.ok) throw new Error('Failed to load tasks');
        return response.json();
    }

    // READ (single)
    async getTask(id) {
        const response = await fetch(this.baseUrl + '/' + id, {
            headers: this.getHeaders()
        });

        if (response.status === 404) {
            throw new Error('Task not found');
        }
        if (!response.ok) throw new Error('Failed to load task');
        return response.json();
    }

    // UPDATE
    async updateTask(id, updates) {
        const response = await fetch(this.baseUrl + '/' + id, {
            method: 'PATCH',
            headers: this.getHeaders(),
            body: JSON.stringify(updates)
        });

        if (!response.ok) {
            const error = await response.json();
            throw new Error(error.message || 'Failed to update task');
        }

        return response.json();
    }

    // DELETE
    async deleteTask(id) {
        const response = await fetch(this.baseUrl + '/' + id, {
            method: 'DELETE',
            headers: this.getHeaders()
        });

        if (!response.ok) throw new Error('Failed to delete task');
        return response.status === 204 ? true : response.json();
    }

    // BATCH operations
    async markMultipleComplete(taskIds) {
        const response = await fetch(this.baseUrl + '/batch-update', {
            method: 'PATCH',
            headers: this.getHeaders(),
            body: JSON.stringify({
                ids: taskIds,
                updates: { status: 'completed' }
            })
        });

        if (!response.ok) throw new Error('Batch update failed');
        return response.json();
    }
}

// Using the TaskAPI
const taskApi = new TaskAPI();

async function initTaskManager() {
    try {
        // Load all pending tasks
        const tasks = await taskApi.getTasks({
            status: 'pending',
            sort: 'due_date'
        });
        renderTaskList(tasks);

        // Create a new task
        const newTask = await taskApi.createTask(
            'Review pull requests',
            'Review the 3 pending PRs on the main repository',
            '2025-06-15'
        );
        console.log('Created task:', newTask.id);

        // Update the task
        await taskApi.updateTask(newTask.id, {
            status: 'in_progress',
            priority: 'high'
        });

        // Delete a task
        await taskApi.deleteTask(newTask.id);

    } catch (error) {
        console.error('Task manager error:', error.message);
    }
}

Example 4: Handling Authentication Expiration

Example: Auto-Refresh Token on 401

class AuthenticatedFetch {
    constructor(baseUrl) {
        this.baseUrl = baseUrl;
        this.token = localStorage.getItem('accessToken');
        this.refreshToken = localStorage.getItem('refreshToken');
        this.refreshing = null; // Holds the refresh promise to avoid duplicates
    }

    async fetch(endpoint, options = {}) {
        const url = this.baseUrl + endpoint;
        const config = {
            ...options,
            headers: {
                'Content-Type': 'application/json',
                'Authorization': 'Bearer ' + this.token,
                ...options.headers
            }
        };

        let response = await fetch(url, config);

        // If unauthorized, try refreshing the token
        if (response.status === 401 && this.refreshToken) {
            await this.doRefresh();

            // Retry the original request with the new token
            config.headers['Authorization'] = 'Bearer ' + this.token;
            response = await fetch(url, config);
        }

        if (!response.ok) {
            throw new Error('Request failed: ' + response.status);
        }

        if (response.status === 204) return null;
        return response.json();
    }

    async doRefresh() {
        // If already refreshing, wait for the existing refresh to complete
        if (this.refreshing) {
            return this.refreshing;
        }

        this.refreshing = (async () => {
            try {
                const response = await fetch(this.baseUrl + '/auth/refresh', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ refresh_token: this.refreshToken })
                });

                if (!response.ok) {
                    throw new Error('Refresh failed');
                }

                const data = await response.json();
                this.token = data.access_token;
                this.refreshToken = data.refresh_token;
                localStorage.setItem('accessToken', this.token);
                localStorage.setItem('refreshToken', this.refreshToken);

            } catch (error) {
                // Refresh failed -- user needs to log in again
                localStorage.removeItem('accessToken');
                localStorage.removeItem('refreshToken');
                window.location.href = '/login';
                throw error;
            } finally {
                this.refreshing = null;
            }
        })();

        return this.refreshing;
    }
}

// Usage
const api = new AuthenticatedFetch('https://api.example.com');

async function loadUserData() {
    const profile = await api.fetch('/profile');
    const orders = await api.fetch('/orders?limit=5');
    return { profile, orders };
}

Fetch Configuration Best Practices

Here is a summary of recommended configuration patterns for different types of requests.

Example: Configuration Reference

// Reading data (GET)
fetch(url, {
    method: 'GET',
    headers: { 'Accept': 'application/json' },
    credentials: 'same-origin'
});

// Sending JSON (POST/PUT/PATCH)
fetch(url, {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
    },
    body: JSON.stringify(data)
});

// Uploading files
fetch(url, {
    method: 'POST',
    // No Content-Type header for FormData!
    body: formData
});

// Authenticated request
fetch(url, {
    headers: { 'Authorization': 'Bearer ' + token }
});

// Request with timeout
const controller = new AbortController();
setTimeout(() => controller.abort(), 10000);
fetch(url, { signal: controller.signal });

// Cross-origin with cookies
fetch(url, {
    mode: 'cors',
    credentials: 'include'
});

Practice Exercise

Build a complete blog client application using the Fetch API and async/await. Use a free API like JSONPlaceholder (https://jsonplaceholder.typicode.com) as your backend. Create a BlogService class with methods for all CRUD operations: getPosts(page, limit) to fetch paginated posts, getPost(id) to fetch a single post, createPost(title, body) to create a new post, updatePost(id, updates) to update a post with PATCH, and deletePost(id) to delete a post. Add a getPostWithComments(id) method that uses Promise.all() to fetch the post and its comments in parallel. Implement proper error handling that distinguishes between network errors and HTTP errors. Add an AbortController-based search method that cancels previous search requests when a new one is made. Add a fetchWithRetry() helper that retries failed requests up to 3 times with exponential backoff. Write a downloadPostsAsJson() method that fetches all posts and creates a downloadable Blob file. Test every method and verify that network error messages are different from HTTP error messages.