How to Write a Lightweight JavaScript Fetch Wrapper

Learn how to create a JavaScript fetch wrapper under 1kb to streamline API calls and improve your app’s performance.

#javascript
#nodejs
#npm
How to Write a Lightweight JavaScript Fetch Wrapper
Picture by Chewy

Frontend development evolved really fast since the adoption of npm modules: hundreds of thousand packages have been released thanks to the Open Source world, making available dependencies for any developer’s need.

However, sometimes the problems we face don’t require a complex logic, meaning we can implement what is required even without installing external dependencies.

This has some advantages:

  • You have full control of your code. In case your business logic needs a change in the code, you can directly act on it with no need to wait for a dependency update or mixing logic to trick the package you are using.
  • You can implement what you need, nothing else. Most famous modules are amazing because they provide lots of features and flexibility, but do we really need a 30kb bundle for something we can really do with 10 lines of code?

The API client requirements

Almost every web app written in JavaScript needs to handle some logic to trigger HTTP requests and correctly fetch the required data. The community brought tons of solution across the years, starting from the well know request module (now deprecated), to the well-known axios.

Those are surely great abstractions, but we can build something way lighter and with similar features with much less code.

We’ll now build the API client I usually write, depending on the requirements, which include the following features:

  • Parse the response
  • Handle all requests method
  • Attach query params
  • Multi-domain
  • Handle authenticated requests

Let’s build it step by step together! 🚀

The request wrapper implementation

First of all, let’s write the wrapper function with no extra functionality, which will be the starting point for our implementation. We’ll use the window.fetch built-in function as base request:

js
function request(path, options) {
return fetch(path, options);
}

Since we want to use this client to parse JSON responses, let’s parse the response when received. In the case of my requirements, I expect to consume an API with a fixed structure, which will always send back an object with two properties, data or error and a boolean success:

js
async function parseResponse(res) {
// If a body response exists, parse anx extract the possible properties
const { data, error, success } = res.status !== 204 ? await res.json() : { success: true };

/* Add any custom logic related to how you want to handle the response
*
* In case success is false,
* trigger a new expection to capture later on request call site
*/
if (!success) throw new Error(error.message);
// Otherwise, simply resolve the received data
return data;
}

function request(path, options) {
return fetch(path, options).then(parseResponse);
}

Now we can start adding flexibility to our request function, automating some of the option makings like headers and query params:

js
export function request(url, options) {
const { headers, query = null, method = 'GET', body, ...extraOpts } = options;

// Compose the request configuration object
const reqOptions = {
method,
headers: {
'Content-Type': 'application/json',
...headers
},
...extraOpts
};

// If a body object is passed, automatically stringify it.
if (body) {
reqOptions.body = typeof body === 'object' ? JSON.stringify(body) : body;
}

let queryString = '';
if (query) {
// Convert to encoded string and prepend with ?
queryString = new URLSearchParams(query).toString();
queryString = queryString && `?${queryString}`;
}

return fetch(`${url}${queryString}`, reqOptions).then(parseResponse);
}

So far we already have a good result! We are now able to trigger HTTP requests and parse the response easily with no need for any repetitive operation.

However, there is a margin for improvements! What if our app needs to consume multiple services? We may want to adapt the interface to pick a base URL from a selection in case the request is directed to another service. Let’s adapt the script for this purpose:

js
const hosts = {
MAIN_SERVICE: process.env.MAIN_SERVICE,
SECONDARY_SERVICE: process.env.SECONDARY_SERVICE
};

function request(path, options) {
const {
headers,
query = null,
method = 'GET',
body,
host = hosts.MAIN_SERVICE,
...extraOpts
} = options;

// ...

return fetch(`${host}${path}${queryString}`, reqOptions).then(parseResponse);
}

// Usage
request('/secondary/posts', {
host: hosts.SECONDARY_HOST
});

We are almost done! But how can we make our requests authenticated if a user is logged? We should retrieve from our authentication service the session token (if available), and attach it to the request through the authorization header:

js
function request(path, options = {}) {
const {
headers,
query = null,
method = 'GET',
body,
host = hosts.MAIN_SERVICE,
...extraOpts
} = options;
assertPath(path);

let token = AuthenticationService.getToken();

// Compose the request configuration object
const reqOptions = {
method,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...headers,
},
...extraOpts,
};

// ...

Passing it a conditional spread into the headers object will allow us to override the Authorization property in case we want to pass a custom header.

To finalize our wrapper, I always add some validators for a required field, sometimes could happen to forget passing an environment variable!

js
function requiredEnv(env) {
throw new TypeError(`The ${env} environment variable is strictly required.`);
}

function assertPath(path) {
const type = typeof path;
if (type !== 'string') {
throw new TypeError(`The path should be a string, instead received a ${type}`);
}
}

const hosts = {
MAIN_SERVICE: process.env.MAIN_SERVICE || requiredEnv('MAIN_SERVICE'),
SECONDARY_SERVICE: process.env.SECONDARY_SERVICE || requiredEnv('SECONDARY_SERVICE'),
};

function request(path, options = {}) {
const {
headers,
query = null,
method = 'GET',
body,
host = hosts.MAIN_SERVICE,
...extraOpts
} = options;

assertPath(path);

// ...

As you can see, It’ll throw an error if an environment variable is missing or the path is not a string.

Wrapping up

With less than 70 lines of code, we got a fully working abstraction flexible enough for all the app requirements. You can adapt it as necessary for your app. The code weight is around 1.9Kb, which goes to less than 1Kb when minified 🚀

I’ll leave here for you a gist with the full implementation and some usage examples:

js
import AuthenticationService from './auth.service';

function requiredEnv(env) {
throw new TypeError(`The ${env} environment variable is strictly required.`);
}

function assertPath(path) {
const type = typeof path;
if (type !== 'string') {
throw new TypeError(`The path should be a string, instead received a ${type}`);
}
}

export const hosts = {
MAIN_SERVICE: process.env.MAIN_SERVICE || requiredEnv('MAIN_SERVICE'),
SECONDARY_SERVICE: process.env.SECONDARY_SERVICE || requiredEnv('SECONDARY_SERVICE')
};

async function parseResponse(res) {
// If a body response exists, parse anx extract the possible properties
const { data, error, success } = res.status !== 204 ? await res.json() : { success: true };

/* Add any custom logic related to how you want to handle the response
*
* In case success is false,
* trigger a new expection to capture later on request call site
*/
if (!success) throw new Error(error.message);
// Otherwise, simply resolve the received data
return data;
}

export function request(path, options = {}) {
const {
headers,
query = null,
method = 'GET',
body,
host = hosts.MAIN_SERVICE,
...extraOpts
} = options;
assertPath(path);

let token = AuthenticationService.getToken();

// Compose the request configuration object
const reqOptions = {
method,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...headers
},
...extraOpts
};

// If a body object is passed, automatically stringify it.
if (body) {
reqOptions.body = typeof body === 'object' ? JSON.stringify(body) : body;
}

let queryString = '';
if (query) {
// Convert to encoded string and prepend with ?
queryString = new URLSearchParams(query).toString();
queryString = queryString && `?${queryString}`;
}

return fetch(`${host}${path}${queryString}`, reqOptions).then(parseResponse);
}

Last updated: