The Ugly Truth: All Popular fetch() Wrappers Do It Wrong

Please have a look at the following two pieces of code. The purpose is to do something (writing to the console) if the value of x is false.

const x = false;
try {
    if (!x) {
        throw new Error("x is false");
    }
}
catch {
    console.log('x is false');
}

const x = false;
if (!x) {
    console.log('x is false')
}

What can we say about these two when we compare them? In a nutshell, we can declare the first one madness. Why would I want to throw just because some variable’s value is false? Clearly, I should do as the second script does: Simple branching.

Well, if you use axios, ky or literally any other fetch() wrapper that throws on non-OK responses, then you’re walking the path of madness.

The Problem

This is evident when simplifying the issue as we did above: The problem is using try..catch as a branching mechanism. But does this matter? Turns out that it does.

This benchmark compares the madness route with regular branching, and the performance loss is huge: About 40% slower if you do madness:

This is true for most (or all, perhaps) runtimes: Throwing is an expensive operation because callstack unwinding is expensive.

I know, nay-sayers will comment on this post and say, “that’s the minority of the cases, almost always response.ok is true so this doesn’t matter”, and it’s a statement with substance. But do you know what’s even better? NOT THROWING. Stop using try..catch as a branching mechanism. It is not its function.

It is mind-blowing how long this awful detail has gone unnoticed.

Is that It?

Well, how do you feel about being forced to write more code because you were sold the idea that interceptors are a cool thing? Don’t believe it? Let’s find out.

TASK: Create code that fetches data. Make sure the fetcher object/function always include the JWT in the Authorization header.

Pretty standard stuff. Fetching 101, if this were a college course.

Using ky

Taken from their README and modifying a bit:

import ky from 'ky';

export const api = ky.extend({
    hooks: {
        beforeRequest: [
            request => {
                request.headers.set('Authorization', `Bearer ${getToken()}`);
            }
        ]
    }
});

Writing a myFetch Function

The same thing with none of these packages:

export function myFetch(url, options) {
    options.headers = { ...options.headers, Authorization: `Bearer ${getToken()}` };
    return fetch(url, options);
}

Was I lying? Is it not more code with ky?

“More code” is not the only consequence: By using ky, you are forcing the developers that maintain your project to learn ky’s API. You’re asking people to learn an API that works against the project’s best interests of performance and maintainability, in exchange for … ? Why, again, are you using ky or axios or any other that behaves like this?

The Solution

The solution is obvious: The standardized fetch() API does a great job on its own. Most of your small to medium-sized projects won’t need anything else. Just write a customized data-fetching function based on fetch() and you’re good to go.

“But I need progress, and plug-ins, and other fancy stuff”. You might, I agree. Search the NPM registry thoroughly. For example, you can find at least one NPM package that allows for progress using fetch(). Other “fancy” requirements could probably be fulfilled just as well.

Is your need still not fulfilled? Search for a good wrapper. I’ll save you the trouble: Because I’m pretty sure I’m the only one in the world that complains about all this, my wrapper dr-fetch is most likely the only wrapper I can recommend.

dr-fetch builds upon the idea of a customized data-fetching function, and it has a unique feature not found in any other wrapper in the world: It lets you type the HTTP response’s body depending on the HTTP status code:

// fetcher.ts
import { DrFetch, setHeaders } from 'dr-fetch';

function myFetch(url: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) {
    init ??= {};
    setHeaders(init, {
        Accept: 'application/json',
        Authorization: `Bearer ${getToken()}`,
    });
    return fetch(url, init);
}

export default new DrFetch(myFetch);

//Consume the exported object and fetch:
import fetcher from './fetcher.js';

const response = await fetcher
    .for<200, MyData[]>()
    .for<401, undefined>()
    .for<400, ValidationError[]>()
    .fetch('/api/data/?sort=desc');

// The response object is now strongly-typed for status codes 200, 400 and 401:
// TypeScript narrowing will work properly:
if (response.status === 200) {
    console.log('Data: %o', response.body.map(x => x.id));
}
else if (response.status === 400) {
    console.log('Errors: %s', response.body.map(error => error.message).join('\n'));
}
else if (response.status === 401) {
    deleteSavedToken();
}

Do you see how nice the above code is? Simple branching, and not a single try..catch in sight (or callbacks or whatever abstraction other wrappers use for interceptors).

NOTE: You would need a try..catch for network errors, like DNS failure, disconnection and the like, but not for business logic.

I don’t claim to be an expert at “fancy” fetching things, but if you open an issue in the repository, I will answer you. Feel free to ask for features that you think might be good. You are welcome to contribute directly, if that’s your preference.