If you've ever seen this error:

SyntaxError: Unexpected token < in JSON at position 0

...you've probably been bitten by the Fetch API's weirdest quirk. You tried to parse JSON, but the server returned an HTML error page instead. And Fetch didn't throw an error. It happily returned that 404 page as if everything was fine.

I spent an embarrassing amount of time debugging this the first time it happened. Let me save you that pain.

Why Two awaits?

New developers often get confused by this pattern:

async function getUser(id) {
    const response = await fetch(`/api/users/${id}`);  // Wait #1
    const data = await response.json();                 // Wait #2
    return data;
}

Why do we need two awaits? Here's what's happening:

The first await resolves when the server sends back headers—the HTTP status code, content type, etc. At this point, the body might still be streaming over the network. You have a response object, but not the actual data yet.

The second await (response.json()) waits for the entire body to download, then parses it as JSON. If the body is large, this could take a while.

Makes sense when you think about it—but it's not obvious at first.

The response.ok Problem

Here's the thing that trips everyone up: Fetch doesn't reject on HTTP errors.

If the server returns a 404 or 500, Fetch considers that a successful request. The server responded, after all. Fetch only rejects when something goes wrong at the network level—DNS failure, connection refused, that sort of thing.

So this code has a bug:

// ❌ Bug: 404s and 500s don't throw
async function getUser(id) {
    try {
        const response = await fetch(`/api/users/${id}`);
        return await response.json();  // Boom! Tries to parse HTML error page
    } catch (error) {
        console.log("This only catches network errors");
    }
}

You need to check response.ok yourself:

// ✅ Correct: explicitly check for HTTP errors
async function getUser(id) {
    const response = await fetch(`/api/users/${id}`);
    
    if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    
    return await response.json();
}

This is why I always build a wrapper around Fetch (more on that below). I don't want to remember to check response.ok in every single API call.

Sending Data

Fetch is more explicit than Axios when sending data. You have to specify everything yourself:

async function createUser(userData) {
    const response = await fetch('/api/users', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',  // Don't forget this!
        },
        body: JSON.stringify(userData)  // Must stringify manually
    });

    if (!response.ok) {
        throw new Error('Failed to create user');
    }
    
    return await response.json();
}

If you forget Content-Type: application/json, your backend will probably receive an empty object. Express, for example, doesn't know how to parse the body without that header. I've seen this bug in production more times than I'd like to admit.

Canceling Requests

This comes up a lot in React. User types in a search box: "app"... "appl"... "apple". That's three API requests. If the "appl" request happens to return after "apple", your UI shows stale results.

The fix is to cancel outdated requests using AbortController:

let controller;

async function search(query) {
    // Cancel any in-flight request
    if (controller) {
        controller.abort();
    }
    
    // Create new controller for this request
    controller = new AbortController();
    
    try {
        const response = await fetch(`/api/search?q=${query}`, {
            signal: controller.signal
        });
        const results = await response.json();
        displayResults(results);
    } catch (error) {
        if (error.name === 'AbortError') {
            // Request was canceled, ignore
            return;
        }
        // Handle real errors
        console.error(error);
    }
}

In React, you'd typically create the controller in a useEffect and call controller.abort() in the cleanup function. This prevents that classic bug where your component unmounts but the API callback still tries to update state.

A Minimal Fetch Wrapper

After writing the same boilerplate enough times, I started using a simple wrapper. Here's a stripped-down version:

const API_BASE = 'https://api.myapp.com';

async function api(endpoint, options = {}) {
    const url = `${API_BASE}/${endpoint}`;
    
    const config = {
        ...options,
        headers: {
            'Content-Type': 'application/json',
            ...options.headers,
        },
    };
    
    // Add auth token if we have one
    const token = localStorage.getItem('token');
    if (token) {
        config.headers.Authorization = `Bearer ${token}`;
    }
    
    // Stringify body if present
    if (options.body && typeof options.body === 'object') {
        config.body = JSON.stringify(options.body);
    }
    
    const response = await fetch(url, config);
    
    if (!response.ok) {
        const error = await response.text();
        throw new Error(error || `HTTP ${response.status}`);
    }
    
    return response.json();
}

// Usage
api('users/1');
api('users', { method: 'POST', body: { name: 'John' } });

Nothing fancy. It just handles the repetitive stuff: base URL, JSON headers, auth tokens, and proper error checking. Everything in one place, easy to modify.

Should You Just Use Axios?

Honestly? Axios is fine. It handles the response.ok thing automatically and has a cleaner API for common cases.

But Fetch is built into every browser. No extra dependency, no bundle size increase. For most projects, a small wrapper like the one above gives you 90% of what Axios offers with zero added kilobytes.

Just remember: always check response.ok. That's really the main thing.

← Back to JavaScript Articles

Back to Home