In an earlier post,
I've already given an overview of how to interact with an API using fetch. In this article, I'd like to dig deeper into two more detailed use-cases:
Monitor the download progress while making an HTTP request.
This is the UI we will start off with. The progress indicator will visualize the fetch - progress
So spin up your favorite code editor and let's dive in.
Before starting with the advanced stuff, let's build up a simple function. The task is to develop a piece of utility code that allows you to search for universities. Fortunately,
Hipo
has just the tool to build up upon.
I'd like to restrict my search to all universities in the USA with a query.
On the technical side, I'd like to keep my fetch logic inside a wrapper function.
That being said, let's start by adding the following code to the
client.js
file:
exportdefaultfunctionhttp(rootUrl){letloading=false;letchunks=[];letresults=null;leterror=null;// let controller = null; // We will get to this variable in a secondconstjson=async (path,options,)=>{loading=truetry{constresponse=awaitfetch(rootUrl+path,{...options});if (response.status>=200&&response.status<300){results=awaitresponse.json();returnresults}else{thrownewError(response.statusText)}catch (err){error=errresults=nullreturnerror}finally{loading=falsereturn{json}
// Import the fetch client and initalize itimporthttpfrom'./client.js';const{json}=http('http://universities.hipolabs.com/');// Grab the DOM elementsconstprogressbutton=document.getElementById('fetch-button');// Bind the fetch function to the button's click eventprogressbutton.addEventListener('click',async ()=>{constuniversities=awaitjson('search?country=United+States');console.log(universities);Enter fullscreen modeExit fullscreen mode
To monitor progress, we need to rebuild a good part of the standard .json() method. It also implicates that we will also have to take care of assembling the response body, chunk by chunk.
I've written an article about handling Node.js streams earlier. The approach shown here is quite similar.
So let's add the following to the client.js file, right below the json function:
exportdefaultfunctionhttp(rootUrl){// ... previous functionsconst_readBody=async (response)=>{constreader=response.body.getReader();// Declare received as 0 initiallyletreceived=0;// Loop through the response stream and extract data chunkswhile (loading){const{done,value}=awaitreader.read();if (done){// Finish loading loading=false;}else{// Push values to the chunk arraychunks.push(value);// Concat the chinks into a single arrayletbody=newUint8Array(received);letposition=0;// Order the chunks by their respective positionfor (letchunkofchunks){body.set(chunk,position);position+=chunk.length;// Decode the response and return itreturnnewTextDecoder('utf-8').decode(body);return{json}Enter fullscreen modeExit fullscreen mode
Note that this function does not work if the content-length header is not configured on the serverside.
As we already have the variable received available, let's add content-length to our _readBody function:
const_readBody=async (response)=>{constreader=response.body.getReader();// This header must be configured serversideconstlength=+response.headers.get('content-length');// Declare received as 0 initiallyletreceived=0;// ...if (done){// Finish loadingloading=false;}else{// Push values to the chunk arraychunks.push(value);// Add on to the received lengthreceived+=value.length;Enter fullscreen modeExit fullscreen mode
With that, we have all relevant indicator values available. What is missing is a way to emit them to the calling function. That can easily be done by using a Javascript framework's reactive features, like React Hooks or Vue's composition API. In this case, however, we'll stick with a builtin browser feature called CustomEvent.
One for whenever a data chunk is read, event fetch-progress.
One for when the fetch request is finished, event fetch-finished.
Both events will be bound to the window object. Like this, they'll be available outside of the http - function's scope.
Inside the _readBody(), adjust the while... loop as follows:
const_readBody=async (response)=>{// ...// Loop through the response stream and extract data chunkswhile (loading){const{done,value}=awaitreader.read();constpayload={detail:{received,length,loading}}constonProgress=newCustomEvent('fetch-progress',payload);constonFinished=newCustomEvent('fetch-finished',payload)if (done){// Finish loadingloading=false;// Fired when reading the response body finisheswindow.dispatchEvent(onFinished)}else{// Push values to the chunk arraychunks.push(value);received+=value.length;// Fired on each .read() - progress tickwindow.dispatchEvent(onProgress);// ... Enter fullscreen modeExit fullscreen mode
The final step to take is catching both custom events and change the progress bar's value accordingly. Let's jump over to the main.js file and adjust it as follows:
Grab some relevant DOM elements
Add the event listener for fetch-progress
Add the event listener for fetch-finished
We can then access the progress values by destructuring from the e.detail property and adjust the progress bar value.
// Grab the DOM elementsconstprogressbar=document.getElementById('progress-bar');constprogressbutton=document.getElementById('fetch-button');constprogresslabel=document.getElementById('progress-label');const{json}=http('http://universities.hipolabs.com/');constsetProgressbarValue=(payload)=>{const{received,length,loading}=payload;constvalue=((received/length)*100).toFixed(2);progresslabel.textContent=`Download progress: ${value}%`;progressbar.value=value;// Bind the fetch function to the button's click eventprogressbutton.addEventListener('click',async ()=>{constuniversities=awaitjson('search?country=United+States');console.log(universities);window.addEventListener('fetch-progress',(e)=>{setProgressbarValue(e.detail);window.addEventListener('fetch-finished',(e)=>{setProgressbarValue(e.detail);Enter fullscreen modeExit fullscreen mode
And there we have it - you can now monitor your fetch request's progress.
Still, there are some adjustments to be made:
Reset the scoped variables
Allow the user to cancel the request
If you've come this far with reading, stay with me for a few more lines.
letcontroller=null;// Make sure to uncomment this variableconstjson=async (path,options,)=>{_resetLocals();loading=true// ... rest of the json function// ... rest of the http functionEnter fullscreen modeExit fullscreen mode
Using the created AbortController, we can now create a signal. It serves as a communication interface between the controller itself and the outgoing HTTP request. Imagine it like a built-in kill switch.
To set it up, modify your client.js file like this:
Create the signal & pass it into the fetch request options.
Create a new function that calls the controller's abort function.
constjson=async (path,options,)=>{_resetLocals();letsignal=controller.signal;loading=truetry{constresponse=awaitfetch(rootUrl+path,{signal,...options});// ... rest of the trycatch function// ... rest of the json function// Cancel an ongoing fetch requestconstcancel=()=>{_resetLocals();controller.abort();// Make sure to export cancelreturn{json,cancel}Enter fullscreen modeExit fullscreen mode
// ... other variable declarationsconstabortbutton=document.getElementById('abort-button');const{json,cancel}=http('http://universities.hipolabs.com/');// ... other functions and event listenersabortbutton.addEventListener('click',()=>{cancel()alert('Request has been cancelled')Enter fullscreen modeExit fullscreen mode
I have recreated this functionality with Vue 3's Composition API. If you are looking to implement monitoring and cancelling fetch requests in your Vue app, you should have a look into this Gist:
Unfortunately, by the time I researched for this article, I could not find a common way to monitor upload progress. The official whatwg Github repository has an open issue on a feature named FetchObserver. However, it seems we'll have to be patient for it to be implemented. Perhaps, it will make the features described in this article easier as well. The future will tell.
The ok read-only property of the Response interface contains a Boolean stating whether the response was successful (status in the range 200-299) or not.
Replace (response.status >= 200 && response.status < 300) with (response.ok)
You're right. Unfortunately, in the browser, we can only do so much and are heavily reliant on a proper interface implementation.
Then again, since we're dealing with relative values in the UI (in this case), I don't think it's a big hindrance.
If I wanted the actual values, my guess would be to implement a x-content-length on the serverside and use it instead of the content-length.
I ran into the same problem, I simply implemented a correct content-length header on my server.
Great article. I appreciate your posts!
Great article, thanks!
It seems that upload progress tracking is now possible with Chromium versions > 105.