Building a Custom Fetch API from XMLHttpRequest

Person typing on laptop

Web developers today have it pretty good when it comes to making HTTP requests in JavaScript. The built-in window.fetch API provides a clean, promise-based interface for fetching resources across the network. But it wasn‘t always so simple.

Before fetch, we had the XMLHttpRequest (XHR) object. While it got the job done, using XHR directly could be verbose and complex, especially for more advanced use cases. You had to manually construct the request, attach event handlers, and parse the response.

Fetch abstracts away a lot of those details and makes common HTTP requests much more straightforward. But what‘s actually going on under the hood of fetch? What if we wanted to construct our own custom implementation of a fetch-like API for learning purposes?

In this post, we‘ll do exactly that. We‘ll progressively build out a custom asynchronous request API modeled after fetch, but implemented using XMLHttpRequest. Along the way, we‘ll gain a deeper understanding of how XHR works, how fetch improves upon it, and how promises simplify asynchronous programming in JavaScript.

The Basics: Making an XHR Request

Let‘s start with the core of our custom fetch API. We‘ll create a function that takes in a URL string and returns an object with a .then() method for handling the response asynchronously. Under the hood, it will use XMLHttpRequest to make the actual request to the server.

Here‘s our first pass:

function customFetch(url) {
  const xhr = new XMLHttpRequest();

  xhr.open(‘GET‘, url);

  xhr.onload = function() {
    if (xhr.status === 200) {
      // Success!
    } else {
      // Something went wrong
    }
  };

  xhr.send();

  return {
    then: function(handleResponse) {
      // We‘ll add code here later
    }
  };
}

Let‘s break this down:

  1. We create a new XMLHttpRequest instance called xhr.
  2. We initialize the request using xhr.open(), passing the HTTP method (GET) and the URL to fetch.
  3. We set up an onload handler that will be called when the request completes. It checks the status code to determine if the request succeeded or failed.
  4. We send the request with xhr.send().
  5. We return an object with a .then() method that will be used to handle the response. We‘ll fill in that method a bit later.

This is the basic structure, but it‘s missing some key functionality. Most notably, we‘re not actually doing anything with the response when the request completes. Let‘s fix that.

Handling the Response

To access the response data, we can use the xhr.responseText property inside the onload handler. Let‘s update our code to pass this response to the function provided to .then():

function customFetch(url) {
  const xhr = new XMLHttpRequest();

  let onSuccess;

  xhr.open(‘GET‘, url);

  xhr.onload = function() {
    if (xhr.status === 200) {
      if (onSuccess) {
        onSuccess(xhr.responseText);
      }
    } else {
      // Something went wrong
    }
  };

  xhr.send();

  return {
    then: function(handleResponse) {
      onSuccess = handleResponse;
    }
  };
}

Here‘s what changed:

  1. We added an onSuccess variable to store the function passed to .then().
  2. Inside the onload handler, if the request was successful, we check if onSuccess is defined. If so, we call it and pass in xhr.responseText.
  3. In the .then() method, we store the handleResponse function in the onSuccess variable.

Now we can use our customFetch function like this:

customFetch(‘https://api.example.com/data‘).then(function(response) {
  console.log(response);
});

When the request completes successfully, the response data will be logged to the console.

Chaining Promises

One of the nice things about the real fetch API is that it‘s promise-based, which allows for clean chaining of async operations using .then(). Can we add similar functionality to our custom version?

Yes! We just need to have each .then() call return a new promise that resolves with the result of the previous one. Here‘s an updated version that supports chaining:

function customFetch(url) {
  return new Promise(function(resolve, reject) {
    const xhr = new XMLHttpRequest();

    xhr.open(‘GET‘, url);

    xhr.onload = function() {
      if (xhr.status === 200) {
        resolve(xhr.responseText);
      } else {
        reject(new Error(xhr.statusText));
      }
    };

    xhr.onerror = function() {
      reject(new Error(‘Network error‘));
    };

    xhr.send();
  });
}

The key changes:

  1. Instead of manually storing the success handler, we‘re now returning a new Promise that wraps the entire XHR request.
  2. When the request succeeds, we call resolve(xhr.responseText) to resolve the promise with the response data.
  3. If the request fails, we call reject() with an error object containing the status text or a generic "Network error" message.
  4. We also added an onerror handler to reject if a network error occurs.

Using this version, we can chain multiple .then() calls like so:

customFetch(‘https://api.example.com/data‘)
  .then(function(response) {
    return JSON.parse(response);
  })
  .then(function(data) {
    console.log(data);
  })
  .catch(function(error) {
    console.error(error);
  });

Each .then() receives the resolved value of the previous promise and returns a new promise, allowing us to transform the response data step-by-step. The .catch() at the end will handle any errors that occur along the way.

Adding More Features

We could stop here, but let‘s add a few more features to bring our customFetch even closer to parity with window.fetch.

First, let‘s support custom request methods and bodies. We‘ll change the function signature to customFetch(url, options), where options is an object that can include method, body, and headers properties:

function customFetch(url, options = {}) {
  return new Promise(function(resolve, reject) {
    const xhr = new XMLHttpRequest();

    xhr.open(options.method || ‘GET‘, url);

    if (options.headers) {
      Object.entries(options.headers).forEach(function([key, value]) {
        xhr.setRequestHeader(key, value);
      });
    }

    xhr.onload = function() {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(xhr.responseText);
      } else {
        reject(new Error(xhr.statusText));
      }
    };

    xhr.onerror = function() {
      reject(new Error(‘Network error‘));
    };

    xhr.send(options.body);
  });
}

The new features:

  1. The options object is merged with defaults using destructuring syntax with default values.
  2. The HTTP method is set using options.method, defaulting to GET.
  3. Any headers provided in options.headers are set using xhr.setRequestHeader().
  4. The request body is sent using xhr.send(options.body).
  5. We now consider any status code in the 200-299 range as a success.

With these changes, we can make POST requests with JSON bodies:

const requestOptions = {
  method: ‘POST‘,
  headers: { ‘Content-Type‘: ‘application/json‘ },
  body: JSON.stringify({ foo: ‘bar‘ })
};

customFetch(‘https://api.example.com/data‘, requestOptions)
  .then(function(response) {
    console.log(response);
  })
  .catch(function(error) {
    console.error(error);
  });

Finally, let‘s add a timeout option to abort the request if it takes too long:

function customFetch(url, options = {}) {
  const controller = new AbortController();
  const { signal } = controller;

  const promise = new Promise(function(resolve, reject) {
    const xhr = new XMLHttpRequest();

    xhr.open(options.method || ‘GET‘, url);

    if (options.headers) {
      Object.entries(options.headers).forEach(function([key, value]) {
        xhr.setRequestHeader(key, value);
      });
    }

    if (signal) {
      signal.addEventListener(‘abort‘, function() {
        xhr.abort();
        reject(new Error(‘Aborted‘));
      });
    }

    xhr.onload = function() {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(xhr.responseText);
      } else {
        reject(new Error(xhr.statusText));
      }
    };

    xhr.onerror = function() {
      reject(new Error(‘Network error‘));
    };

    xhr.send(options.body);
  });

  if (options.timeout) {
    setTimeout(function() {
      controller.abort();
    }, options.timeout);
  }

  return promise;
}

Key additions:

  1. We create a new AbortController and get its associated AbortSignal.
  2. We add a listener to the signal that aborts the XHR request and rejects the promise if the signal is aborted.
  3. If a timeout option is provided, we use setTimeout() to abort the request after the specified number of milliseconds.

Here‘s how you‘d use the timeout feature:

customFetch(‘https://slowapi.example.com/data‘, { timeout: 5000 })
  .then(function(response) {
    console.log(response);
  })
  .catch(function(error) {
    if (error.message === ‘Aborted‘) {
      console.log(‘Request timed out‘);
    } else {
      console.error(error);
    }
  });

Wrapping Up

And there we have it! Our custom fetch API can now make GET and POST requests, handle success and error responses, chain promises, and abort requests after a timeout.

While our version is still a simplified implementation compared to the full fetch spec, it highlights many of the key concepts. We can see how fetch provides a more modern, promise-based interface over the lower-level XMLHttpRequest API.

Some key points to remember:

  • XMLHttpRequest is the underlying browser API that allows making HTTP requests.
  • Fetch is a higher-level API that simplifies many common use cases and uses promises for cleaner async handling.
  • You can implement many of the core features of fetch using XHR, including promise chaining, request/response intercepting, and aborting requests.
  • However, the real fetch API still offers more advanced capabilities not covered here, like streaming responses, caching controls, and richer error handling.

I encourage you to play around with the code and see what other improvements you can make. And for production use, I still recommend using the built-in fetch or a well-tested library over rolling your own implementation completely.

I hope this post has given you a better understanding of what‘s happening under the hood of fetch and how it relates to XMLHttpRequest. Let me know in the comments if you have any questions!

Similar Posts