Multi File Uploader built with Native JS and Promises

Published

The code is available on Github here.

Up until recently, one of my managed sites was using a Flash plugin for the upload form (Uploadify). It worked well, but Flash is a thing of the past, and it was clear I needed a modern replacement.

I found plenty of plugins for multi file upload, but none were exactly what I was looking for. I ended up creating my own to address these requirements:

  • Multiple file selection
  • Mobile friendly
  • AJAX upload
  • Progress indicators
  • Support large batch uploads, for example 20-30 photo files; led to a requirement for sequential file uploads
  • No framework or plugin dependencies, strictly native JavaScript

Below is a guide explaining the steps I took when creating the uploader, with an explanation of how the JavaScript and asynchronous logic works.

Let’s take a look!

Setting the Stage

The two most common ways to handle a multifile upload are:

  1. Bundling all files and form data together into a single POST request
  2. Creating a separate AJAX requests for each file

This guide follows path number 2, which was largely driven by the design of the backend system processing the uploads ( files were split up and processed individually anyways). I also felt that separate AJAX requests was more stable when uploading large batches of files.

1. The Basic AJAX Multi File Upload Form

I started by defining my HTML form, which consisted of a file input with the multiple attribute set, and a submit button. Notice the enctype attribute on the form element; this is required when submitting forms via AJAX.

<form id="file-upload-form" action="upload-backend.php" method="post" enctype="multipart/form-data">
    <input id="file-upload" name="fileUpload" type="file" multiple="multiple" />
    <input id="submit-button" type="submit" value="Submit"/>
</form>

The accompanying JavaScript logic was executed inside a callback function firing on the “DOMContentLoaded” event (similar to jQuery’s $(document).ready). As part of this logic, I added a “change” event listener on the file input that created a listitem (li tag) for each of the selected files, and add it to an un-ordered list in the DOM.

// execute logic on DOM loaded
document.addEventListener("DOMContentLoaded", () => {

  // retrieve important DOM elements
  const fileUploadElem = document.querySelector("input#file-upload")
  const formElem = document.querySelector("form#file-upload-form")
  const selectedFilesList = document.querySelector("ul#selected-files-list")
  
  // file input change event - create list item for each selected file
  fileUploadElem.addEventListener("change", (event) => {
    Array.from(fileUploadElem.files).forEach((file) => {
      let listItem = document.createElement('li')
      listItem.innerHTML = file.name + ' - ' + file.type
      selectedFilesList.appendChild(listItem)
    })
  })

})

The HTML and JavaScript above yielded a simple form that looks something like this when files are selected for upload:

Lastly, to enable the AJAX uploads, I defined another callback function on the form “submit” event. I used the XMLHttpRequest Object to place the AJAX request – while this is possible with the newer Fetch API, I couldn’t figure a way to handle progress indicators using the Fetch API1. I’ll have to check back in later.

// placed at bottom of DOMContentLoaded callback function above
formElem.addEventListener("submit", (event) => {
  // prevent normal form submit behavior 
  event.preventDefault()

  // gather form data including all selected files
  const formData = new FormData(formElem)
  
  // dispatch xhr to start file upload - detect file upload completion and notify user
  let xhr = new XMLHttpRequest()
  xhr.onload = function () {
    if (xhr.readyState === 4 && xhr.status === 200) {
      alert('Upload completed successfully!')
    } else {
      alert('Error during file upload.')
    }
  }
  
  // initiate AJAX request
  xhr.open("POST", formElem.action)
  xhr.send(formData)

})

At this point, the form was set up to submit multiple files via a single AJAX request. While this worked for small uploads, I was suspicious of issues when uploading large batches of files.

Multiple files uploaded as part of the same AJAX request (the final request in list)

2. Seperating the Upload into Individual AJAX Requests for each File

To separate the upload into multiple requests, one for each file, I revisited my form’s “submit” callback function. I wrapped the existing AJAX logic in a Promise, and then used the Promise.all() function to monitor the completion of all uploads (since they are executed asynchronously).

// form submit event - placed at bottom of DOMContentLoaded callback function above
formElem.addEventListener("submit", (event) => {
  // prevent normal form submit behavior 
  event.preventDefault()

  const uploadPromises = []
  
  // loop through each file and upload individually
  Array.from(fileUploadElem.files).forEach((file) => {
    const uploadPromise = new Promise((resolve,reject) => {
      
      // create FormData object - add file and form fields manually
      const formData = new FormData()
      formData.append('fileUpload', file)
      
      // dispatch xhr to start file upload - detect file upload completion and notify user
      let xhr = new XMLHttpRequest()
      xhr.onload = () => {
        if (xhr.readyState === 4 && xhr.status === 200) resolve()
        else reject()
      }
      
      // initiate AJAX request
      xhr.open("POST", formElem.action)
      xhr.send(formData)

    })
    uploadPromises.push(uploadPromise)
  })

  // add notification when all uploads complete
  Promise.all(uploadPromises).then( 
    () => alert('Upload completed successfully!') , 
    () => alert('Error during file upload.') , 
  )

})

After incorporating this logic, separate AJAX requests were placed for each file. The image below shown the Network requests involved with uploading 5 files, each with their own XHR (or AJAX) request.

A separate AJAX request is sent for each file (the last 5 requests in the list)

3. Adding Progress Indicators

I modified the logic in the file input “change” event handler to create HTML Progress elements for each of the selected files.

// file input change event - near top of DOMContentLoaded callback defined above
fileUploadElem.addEventListener("change", (event) => {
  Array.from(fileUploadElem.files).forEach((file) => {

    // create list item for each selected file
    let listItem = document.createElement('li')
    listItem.innerHTML = file.name + ' - ' + file.type
    selectedFilesList.appendChild(listItem)

    // create progress indicator element
    let progressElem = document.createElement('progress')
    progressElem.setAttribute('value',0)
    listItem.appendChild(progressElem)

    // save reference to create DOM element if needed
    //  (not used in this example)

  })
})

Next, I modified the “onload” and “progress” event handlers on the file upload’s XMLHttpRequests request to update the listitem and Progress DOM elements (lines 4,7, and 14).

// set onload (i.e. upload completion) callback
xhr.onload = () => {
  if (xhr.readyState === 4 && xhr.status === 200) {
    selectedFilesList.querySelectorAll('li')[index].style.color = 'darkgreen'
    resolve()
  } else {
    selectedFilesList.querySelectorAll('li')[index].style.color = 'red'
    reject()
  }
}

// watch for file upload progress
xhr.upload.addEventListener('progress', (e) => {
  selectedFilesList.querySelectorAll('progress')[index].setAttribute("value", (e.loaded / e.total * 100) )
})

Luckily I was able to query the DOM for the listitem and Progress elements; in other situations I may have needed to save a reference to these element somewhere, for example the global JavaScript context.

After incorporating these updates, the listitems and Progress elements were updating as each upload progressed.

Scalability Issues: Too Many AJAX Requests when Processing Large Batches

I was planning to upload batches of 30 or 40 photos at a time, and I realized that nothing was preventing the uploader from initiating 30+ AJAX requests as soon as the upload form was submitted. This seemed like an issue since browsers limit the amount of requests per hostname23.

To add stability when uploading larger batch sizes, I needed more control over when each AJAX file upload request was placed. The goal was to prevent all 30+ AJAX requests from being initiated at the same time.

4. Forcing Sequential File Uploads

Thinking back to the form “submit” callback, the upload AJAX requests were wrapped in Promises, and being executed immediately upon Promise creation. By moving the creation of the upload Promises into a standalone function, I could delay the upload Promise creation until when function was called4.

function createUploadPromise(iteration) {

  return new Promise((resolve,reject) => {
    // create FormData object - add file and form fields manually
    const formData = new FormData()
    formData.append('fileUpload', this.fileUploadElem.files[iteration])
    
    // other function contents left out for brevity
    
    // initiate AJAX request
    xhr.open("POST", this.formElem.action)
    xhr.send(formData)

  })

}

I used a full JavaScript function declaration here because I needed the function to have an execution context (essentially its own ‘this‘ Object). Separate execution contexts are not available with the lexical scoping employed with the ES6 Arrow functions5.

Next, I made use of a JavaScript class I wrote called AsyncSequenceIterator, which performs an asynchronous action a designated number of times while ensuring that each iteration completes before the next beings. This ensures asynchronous actions are processed one at a time, rather than all at once in parallel.

Note: An alternative to the AsyncSequenceIterator that makes use of async/await syntax is available here.

To make use of the sequential processing of the AsyncSequenceIterator class, I made a modifications to the form “submit” handler including:

  1. Determining the the number of iterations based on the number of files selected by the file input.
  2. Passing in the createUploadPromise() function as the asynchronous action; remember, each execution of this function would immediately initialize an AJAX file upload request.
  3. Assembling an Object to be set as the execution context for the createUploadPromise() function; the Object included references to the required form and file upload elements.
  4. Defining callbacks function for when the AsyncSequenceIterator had completed. The AsyncSequenceIterator is “thennable“, so I defined “then” and “finally” callbacks to notify the user that uploads were complete.

Here is the “submit” event handler with these updates included:

formElem.addEventListener("submit", (event) => {
  // prevent normal form submit behavior 
  event.preventDefault()
  
  // prepare promise execution context
  const promiseContext = {
    formElem: formElem,
    fileUploadElem: fileUploadElem,
    selectedFilesList: selectedFilesList
  }

  // use AsyncSequenceIterator class to upload files sequentially
  const iterationCount = fileUploadElem.files.length - 1
  new AsyncSequenceIterator(iterationCount, createUploadPromise, promiseContext).whenComplete.then( 
    // success
    () => {
      alert('All uploads completed successfully!')
    },
    // failure
    () => {
      alert('Error during file uploads.')
    }
  // disable any further uploads until page refresh
  ).finally( () => {
    formElem.querySelector('input[type=submit').disabled = true
  })

})

The AJAX file uploads were now being executed sequentially, which makes the uploader much more stable when processing large batch file uploads.

AJAX Requests are dispatched sequentially; each request must complete before the next begins

References

  1. Upload progress indicators for fetch?
  2. How many concurrent AJAX (XmlHttpRequest) requests are allowed in popular browsers?
  3. Browserscope – Network
  4. How to pass parameter to a promise function
  5. ES6 arrow functions, syntax and lexical scoping

Subscribe by Email

Enter your email address below to be notified about updates and new posts.


Comments

Loading comments..

No responses yet

Leave a Reply

Your email address will not be published. Required fields are marked *