Tracking Upload Progress in Browsers

May 10, 2020

A short piece of skele­ton code to demon­strate how to track upload progress in browsers. I’ve always had the impres­sion that most upload­ing/​down­load­ing progress bars were faked or had non-triv­ial imple­men­ta­tions involv­ing sock­ets, but it turns out you can actu­al­ly do this out of the box with the Fetch API (down­load progress only, more on this below) or XHR (both upload and down­load progress).

import React, { useState } from "react";

const uploadUrl = "https://api.mysite.com/upload";

function App() {
  const [file, setFile] = useState<File>();
  const [uploadProgress, setUploadProgress] = useState<[number, number]>([
    0,
    0
  ]);
  return (
    <div className="App">
      <div>
        <button
          onClick={() => {
            if (file) {
              uploadFile(file, evt => {
                setUploadProgress([evt.loaded, evt.total]);
              });
            }
          }}
        >
          Upload file
        </button>
        <input
          type="file"
          name="myfile"
          onChange={e => {
            if (e.target.files && e.target.files.length > 0) {
              setFile(e.target.files[0]);
            }
          }}
        />
        {file && (
          <div>
            {uploadProgress[0]} of {uploadProgress[1]} bytes uploaded
          </div>
        )}
      </div>
    </div>
  );
}

const uploadFile: (
  file: File,
  progressCallback?: (x: ProgressEvent<EventTarget>) => void
) => Promise<string> = async (file, progressCallback) => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const body = new FormData();

    body.append("file", file);

    xhr.upload.onprogress = progressCallback || null;

    xhr.onload = () => {
      if (xhr.status === 200) {
        resolve(xhr.responseText);
      } else {
        reject();
      }
    };
    xhr.open("POST", uploadUrl, true);
    xhr.send(body);
  });
};

export default App;

Let’s take a look at some parts of the code.

const [file, setFile] = useState<File>();
const [uploadProgress, setUploadProgress] = useState<[number, number]>([
  0,
  0
]);

We store 2 pieces of state here to store the file and the progress of the upload. The progress of the upload is rep­re­sent­ed as a 2-tuple. The first number indi­cates the number of bytes uploaded so far, and the second number indi­cates the total size of the request.

Why do we want to main­tain the total size of the request here, instead of get­ting it from the size attribute of the file object?

The answer is because the XHR request size includes not just the file itself but also head­ers and other over­head. If we were to use just the size attribute from the file object, the UI would dis­play some­thing like 7200 of 7000 bytes uploaded when the upload is com­plete.


const uploadFile: (
  file: File,
  progressCallback?: (x: ProgressEvent<EventTarget>) => void
) => Promise<string> = async (file, progressCallback) => {

We define an async/​await ver­sion of uploadFile that accepts a file and a progress call­back func­tion. This call­back func­tion will take in the ProgressEvent object emit­ted by the onprogress event.

This object has the loaded and total attrib­ut­es, which we use to update the state of the upload progress here:

uploadFile(file, evt => {
  setUploadProgress([evt.loaded, evt.total]);
});

return new Promise((resolve, reject) => {
  const xhr = new XMLHttpRequest();
  const body = new FormData();

  body.append("file", file);

Here, notice that we’re using XMLHttpRequest instead of the newer fetch API. This is because fetch does not sup­port mon­i­tor­ing the progress of uploads, only down­loads. This issue is also tracked here.

    xhr.upload.onprogress = progressCallback || null;

We attach the call­back func­tion to the xhr.upload.onprogress event. Do not con­fuse this with the xhr.onprogress func­tion. The latter tracks the down­load progress of the request.