diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js
index e038065ac290f36af5dfffef843f24369df6fbdf..dde106615f11bb218a846a1fd278255cbd266863 100644
--- a/vospace-ui-frontend/src/api/server/index.js
+++ b/vospace-ui-frontend/src/api/server/index.js
@@ -9,7 +9,7 @@ import axios from 'axios';
 import store from '../../store';
 import main from '../../main';
 
-function apiRequest(options, showLoading = true, handleValidationErrors = false) {
+function apiRequest(options, showLoading = true, handleValidationErrors = false, handleAllErrors = false) {
   if (showLoading) {
     store.commit('setLoading', true);
   }
@@ -26,11 +26,17 @@ function apiRequest(options, showLoading = true, handleValidationErrors = false)
         }
       })
       .catch(error => {
-        store.commit('setLoading', false);
-        if (handleValidationErrors && error.response && error.response.status === 400) {
-          reject(error.response.data);
+        if (handleAllErrors) {
+          reject(error);
         } else {
-          main.showError(getErrorMessage(error));
+          if (showLoading) {
+            store.commit('setLoading', false);
+          }
+          if (handleValidationErrors && error.response && error.response.status === 400) {
+            reject(error.response.data);
+          } else {
+            main.showError(getErrorMessage(error));
+          }
         }
       });
   });
@@ -145,7 +151,7 @@ export default {
       source
     };
   },
-  deleteNodes(paths, showLoading = true) {
+  deleteNodes(paths, calledFromUploadModal = false) {
     let url = BASE_API_URL + 'delete';
     return apiRequest({
       method: 'POST',
@@ -155,7 +161,9 @@ export default {
         'Cache-Control': 'no-cache'
       },
       data: paths
-    }, showLoading, true);
+      // if node deletion is called from upload modal loading animation is
+      // ignored and error handling is completely handled by upload manager
+    }, !calledFromUploadModal, true, calledFromUploadModal);
   },
   keepalive() {
     let url = BASE_API_URL + 'keepalive';
diff --git a/vospace-ui-frontend/src/components/modal/UploadFilesModal.vue b/vospace-ui-frontend/src/components/modal/UploadFilesModal.vue
index 158e6b23940f90875ea88bde704f15d7314ba185..af3f2657587e667cd11372b5fae7f34b8710e3a1 100644
--- a/vospace-ui-frontend/src/components/modal/UploadFilesModal.vue
+++ b/vospace-ui-frontend/src/components/modal/UploadFilesModal.vue
@@ -5,25 +5,31 @@
 -->
 <template>
 <b-modal id="upload-files-modal" title="Upload file" okTitle="Upload" @show="reset" @ok.prevent="uploadFiles" :no-close-on-backdrop="blockModal" :no-close-on-esc="blockModal" :hide-header-close="blockModal" :cancel-disabled="blockModal"
-  :ok-disabled="blockModal" size="lg">
+  :ok-disabled="blockModal || hasErrors" size="lg">
   <b-form-file v-model="files" :multiple="true" :state="fileState" placeholder="Choose your files or drop them here..." drop-placeholder="Drop files here..." @change="selectionChanged"></b-form-file>
-  <b-form-invalid-feedback id="upload-file-input-feedback" class="text-right">{{uploadFileError}}</b-form-invalid-feedback>
-  <div class="mt-3" v-if="!blockModal">Selected files: {{ selectedFiles }}</div>
-  <div v-if="creatingMetadata" class="mt-3">
+  <b-form-invalid-feedback id="upload-file-input-feedback" class="text-right">{{ validationError }}</b-form-invalid-feedback>
+  <div class="mt-3" v-if="!blockModal && !hasErrors">Selected files: {{ selectedFiles }}</div>
+  <div v-if="uploadsManager.creatingMetadata" class="mt-3">
     <b-spinner small variant="primary" label="Spinning"></b-spinner>
     Creating metadata...
   </div>
-  <div v-if="uploadInProgress" class="mt-3">
+  <div v-if="uploadsManager.uploadInProgress || hasErrors" class="mt-3">
     <div v-for="(file, index) in files" :key="index" class="upload-progress-container mt-1">
-      <div v-if="!deletionStatuses[index]">
+      <div v-if="uploadsManager.errors[index] !== null">
+        <span class="text-danger">Error for {{ file.name }}: {{ uploadsManager.errors[index] }}</span>
+      </div>
+      <div v-if="uploadsManager.errors[index] === null && uploadsManager.deletionStatuses[index] === null">
         {{ file.name }}<br />
-        <span class="text-danger cancel-upload" @click="cancelUpload(index)">&times;</span>
-        <b-progress :value="progress[index]" :max="100" show-progress animated></b-progress>
+        <span class="text-danger cancel-upload" @click="cancelUpload(index)" v-if="uploadsManager.progress[index] < 100">&times;</span>
+        <b-progress :value="uploadsManager.progress[index]" :max="100" show-progress :animated="uploadsManager.progress[index] < 100"></b-progress>
       </div>
-      <div v-if="deletionStatuses[index]">
+      <div v-if="uploadsManager.errors[index] === null && uploadsManager.deletionStatuses[index] === true">
         <b-spinner small variant="primary" label="Spinning"></b-spinner>
         Upload of {{ file.name }} has been canceled. Waiting for metadata deletion...
       </div>
+      <div v-if="uploadsManager.errors[index] === null && uploadsManager.deletionStatuses[index] === false" class="text-primary">
+        Upload of {{ file.name }} has been canceled.
+      </div>
     </div>
   </div>
 </b-modal>
@@ -31,25 +37,25 @@
 
 <script>
 const maxUploadSize = process.env.VUE_APP_MAX_UPLOAD_SIZE;
-import main from '../../main';
-import client from 'api-client';
-import Vue from 'vue';
 
 export default {
   data() {
     return {
-      files: [], // array of selected files
-      progress: [], // array of upload progress percentages (for tracking upload progress)
-      cancellations: [], // array of axios cancel tokens (for aborting uploads)
-      deletionStatuses: [], // status of upload deletions (true if request is being aborted, false otherwise)
-      creatingMetadata: false, // true when nodes metadata is being created (pre-upload phase)
-      uploadInProgress: false, // true when data is being uploaded
-      uploadFileError: null
+      validationError: null
     }
   },
   computed: {
+    uploadsManager() { return this.$store.state.uploadsManager },
+    files: {
+      get() {
+        return this.uploadsManager.files;
+      },
+      set(files) {
+        this.$store.commit('setFilesToUpload', files);
+      }
+    },
     fileState() {
-      if (this.uploadFileError) {
+      if (this.validationError) {
         return false;
       }
       return null;
@@ -65,7 +71,10 @@ export default {
       return names.join(', ');
     },
     blockModal() {
-      return this.creatingMetadata || this.uploadInProgress;
+      return this.uploadsManager.creatingMetadata || this.uploadsManager.uploadInProgress;
+    },
+    hasErrors() {
+      return this.uploadsManager.errors.filter(e => e !== null).length > 0;
     }
   },
   methods: {
@@ -74,22 +83,26 @@ export default {
       this.resetError();
     },
     resetError() {
-      this.uploadFileError = null;
+      this.validationError = null;
+      this.$store.commit('resetUploadState');
     },
     selectionChanged() {
       this.resetError();
     },
     uploadFiles() {
-      if (this.uploadInProgress || this.creatingMetadata) {
+      if (this.blockModal) {
         return;
       }
+
+      this.$store.commit('resetUploadState');
+
       if (this.files.length === 0) {
-        this.uploadFileError = "Select at least one file";
+        this.validationError = "Select at least one file";
       } else {
         // Check special characters in file names
         for (let file of this.files) {
           if (/[<>?":\\/`|'*]/.test(file.name)) {
-            this.uploadFileError = "File " + file.name + " contains an illegal character. Following characters are not allowed: < > ? \" : \\ / | ' * `";
+            this.validationError = "File " + file.name + " contains an illegal character. Following characters are not allowed: < > ? \" : \\ / | ' * `";
             return;
           }
         }
@@ -97,79 +110,18 @@ export default {
         // Check size limit
         for (let file of this.files) {
           if (file.size >= maxUploadSize * Math.pow(10, 9)) {
-            this.uploadFileError = "File " + file.name + " is too big. Max allowed file size is " + maxUploadSize + " GB";
+            this.validationError = "File " + file.name + " is too big. Max allowed file size is " + maxUploadSize + " GB";
             return;
           }
         }
 
-        for (let i = 0; i < this.files.length; i++) {
-          let file = this.files[i];
-          let reader = new FileReader();
-
-          reader.addEventListener('progress', (event) => {
-            if (event.loaded && event.total) {
-              let percent = (event.loaded / event.total) * 100;
-              this.progress[i] = percent;
-            }
-          });
-
-          reader.readAsDataURL(file);
-        }
-
-        // reset status arrays
-        Vue.set(this, 'progress', []);
-        Vue.set(this, 'cancellations', []);
-        Vue.set(this, 'deletionStatuses', []);
-
-        this.creatingMetadata = true;
-
-        // Upload
-        this.$store.dispatch('uploadFiles', this.files)
-          .then((uploads) => {
-            this.creatingMetadata = false;
-
-            let promises = [];
-            for (let upload of uploads) {
-              promises.push(upload.request);
-              this.cancellations.push(upload.source);
-              this.deletionStatuses.push(false);
-            }
-
-            this.uploadInProgress = true;
-
-            // wait until all downloads have been completed (both successfully or not)
-            return Promise.allSettled(promises);
-          })
-          .then((responses) => {
-
-            let deletionPromises = [];
-            for (let i = 0; i < responses.length; i++) {
-              let response = responses[i];
-
-              if (response.status !== 'fulfilled') {
-                if (this.deletionStatuses[i]) {
-                  deletionPromises.push(client.deleteNodes(['/' + this.$store.state.path + '/' + this.files[i].name], false));
-                } else {
-                  let message = "Unable to upload file " + this.files[i].name;
-                  main.showError(message);
-                }
-              }
-            }
-
-            return Promise.allSettled(deletionPromises);
-          })
-          .finally(() => {
-            // Reload current node when all uploads completed
-            this.$store.dispatch('setPath', this.$store.state.path);
-            this.$bvModal.hide('upload-files-modal');
-            this.creatingMetadata = false;
-            this.uploadInProgress = false;
-          });
+        // $bvModal instance is passed to the upload manager because modal has
+        // to be closed at the end of a successful upload
+        this.$store.dispatch('upload', this.$bvModal);
       }
     },
     cancelUpload(index) {
-      Vue.set(this.deletionStatuses, index, true);
-      this.cancellations[index].cancel();
+      this.$store.dispatch('cancelUpload', index);
     }
   }
 }
diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js
index 63d0fc70715b88f626eaffaa044d3fd81ac5aef3..21640ef240ed5aee5f538e2ceafb10140409796d 100644
--- a/vospace-ui-frontend/src/store.js
+++ b/vospace-ui-frontend/src/store.js
@@ -9,6 +9,7 @@ import Vue from 'vue';
 import Vuex from 'vuex';
 import client from 'api-client';
 import main from './main';
+import uploadsManager from './uploadsManager';
 
 Vue.use(Vuex);
 
@@ -49,6 +50,9 @@ export default new Vuex.Store({
     nodesToArchive: [],
     selectedNotArchivableNodes: []
   },
+  modules: {
+    uploadsManager
+  },
   mutations: {
     setLoading(state, loading) {
       state.loading = loading;
@@ -204,23 +208,6 @@ export default new Vuex.Store({
           dispatch('setPath', state.path);
         });
     },
-    uploadFiles({ state }, files) {
-      let names = [];
-      for (let file of files) {
-        names.push(file.name);
-      }
-      return new Promise((resolve, reject) => {
-        client.prepareForUpload(state.path, names)
-          .then(uploadUrls => {
-            let uploads = [];
-            for (let i = 0; i < files.length; i++) {
-              uploads.push(client.uploadFile(uploadUrls[i], files[i]));
-            }
-            resolve(uploads);
-          })
-          .catch(error => reject(error));
-      });
-    },
     deleteNodes({ state, dispatch }) {
       client.deleteNodes(state.nodesToDelete)
         .then(() => {
diff --git a/vospace-ui-frontend/src/uploadsManager.js b/vospace-ui-frontend/src/uploadsManager.js
new file mode 100644
index 0000000000000000000000000000000000000000..c70ba510e55ed7e6949735f754d21616ca14f127
--- /dev/null
+++ b/vospace-ui-frontend/src/uploadsManager.js
@@ -0,0 +1,181 @@
+/*
+ * This file is part of vospace-ui
+ * Copyright (C) 2021 Istituto Nazionale di Astrofisica
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+import Vue from 'vue';
+import client from 'api-client';
+
+function resetArray(array) {
+  array.splice(0, array.length);
+}
+
+export default {
+  state: {
+    files: [], // array of selected files
+    progress: [], // array of upload progress percentages (for tracking upload progress)
+    cancellations: [], // array of axios cancel tokens (for aborting uploads)
+    deletionStatuses: [], // status of upload deletions (true if request is being aborted, false otherwise)
+    errors: [], // errors happened during downloads
+    creatingMetadata: false, // true when nodes metadata is being created (pre-upload phase)
+    uploadInProgress: false, // true when data is being uploaded
+  },
+  mutations: {
+    setFilesToUpload(state, files) {
+      state.files = files;
+    },
+    resetUploadState(state) {
+      resetArray(state.progress);
+      resetArray(state.cancellations);
+      resetArray(state.deletionStatuses);
+      resetArray(state.errors);
+    },
+    setDeletion(state, data) {
+      Vue.set(state.deletionStatuses, data.index, data.value);
+    },
+    setProgress(state, data) {
+      Vue.set(state.progress, data.index, data.percent);
+    },
+    setCreatingMetadata(state, value) {
+      state.creatingMetadata = value;
+    },
+    setUploadInProgress(state, value) {
+      state.uploadInProgress = value;
+    },
+    setPreUploadResults(state, preUploadResults) {
+      for (let result of preUploadResults) {
+        if (Object.keys(result).includes('request')) {
+          state.cancellations.push(result.source); // axios cancellation token
+          state.deletionStatuses.push(null);
+          state.errors.push(null);
+        } else {
+          state.cancellations.push(null);
+          state.deletionStatuses.push(null);
+          state.errors.push(result);
+        }
+      }
+      state.uploadInProgress = true;
+    },
+    setError(state, data) {
+      Vue.set(state.errors, data.index, data.error);
+    }
+  },
+  actions: {
+    upload({ state, rootState, commit, dispatch }, bvModal) {
+
+      // Progress bars initialization
+      for (let index = 0; index < state.files.length; index++) {
+        let file = state.files[index];
+        let reader = new FileReader();
+
+        reader.addEventListener('progress', (event) => {
+          if (event.loaded && event.total) {
+            let percent = (event.loaded / event.total) * 100;
+            commit('setProgress', { index, percent });
+          }
+        });
+
+        reader.readAsDataURL(file);
+      }
+
+      commit('setCreatingMetadata', true);
+
+      // Create nodes metadata and obtain upload URLs
+      dispatch('prepareUpload')
+        .then((preUploadResults) => {
+          commit('setCreatingMetadata', false);
+
+          let promises = [];
+          for (let result of preUploadResults) {
+            if (Object.keys(result).includes('request')) {
+              promises.push(result.request); // upload http promise
+            } else {
+              // fictitious promise for failed pre-upload event
+              promises.push(new Promise(resolve => resolve()));
+            }
+          }
+
+          commit('setPreUploadResults', preUploadResults);
+
+          // wait until all downloads have been completed (both successfully or not)
+          return Promise.allSettled(promises);
+        })
+        .then((responses) => {
+
+          let deletionPromises = [];
+          for (let index = 0; index < responses.length; index++) {
+            let error = state.errors[index];
+            let response = responses[index];
+
+            if (response.status === 'fulfilled' && error === null) {
+              commit('setProgress', { index, percent: 100 });
+            } else {
+              if (state.deletionStatuses[index] || error === null) {
+                // delete node metadata both when user aborted the upload and when it failed
+                deletionPromises.push(client.deleteNodes(['/' + rootState.path + '/' + state.files[index].name], true));
+                if (state.deletionStatuses[index] === null) {
+                  // e.g. server became unreachable during the upload
+                  commit('setError', { index, error: 'Unable to upload file' });
+                }
+              }
+            }
+          }
+
+          // if there are some deletion promises, wait them
+          return Promise.allSettled(deletionPromises);
+        })
+        .then(deletionResults => {
+          // verify if metadata of aborted uploads has been correctly removed
+          let resultIndex = 0;
+          for (let i = 0; i < state.deletionStatuses.length; i++) {
+            let deletionStatus = state.deletionStatuses[i];
+            if (deletionStatus !== null) {
+              let deletionResult = deletionResults[resultIndex];
+              if (deletionResult.status === 'fulfilled') {
+                commit('setDeletion', { index: i, value: false });
+              } else {
+                commit('setError', { index: i, error: 'Failed to delete node metadata. Manual cleanup may be required.' });
+              }
+              resultIndex++;
+            }
+          }
+        })
+        .finally(() => {
+          // Reload current node when all uploads completed
+          dispatch('setPath', rootState.path);
+          commit('setCreatingMetadata', false);
+          commit('setUploadInProgress', false);
+          let hasErrors = state.errors.filter(e => e !== null).length > 0;
+          if (!hasErrors) {
+            bvModal.hide('upload-files-modal');
+          }
+        });
+    },
+    prepareUpload({ state, rootState }) {
+      let names = [];
+      for (let file of state.files) {
+        names.push(file.name);
+      }
+      return new Promise((resolve, reject) => {
+        client.prepareForUpload(rootState.path, names)
+          .then(preUploadResponses => {
+            let uploads = [];
+            for (let i = 0; i < state.files.length; i++) {
+              let uploadUrl = preUploadResponses[i].url;
+              if (uploadUrl !== null) {
+                uploads.push(client.uploadFile(uploadUrl, state.files[i]));
+              } else {
+                uploads.push(preUploadResponses[i].error);
+              }
+            }
+            resolve(uploads);
+          })
+          .catch(error => reject(error));
+      });
+    },
+    cancelUpload({ state, commit }, index) {
+      commit('setDeletion', { index, value: true });
+      state.cancellations[index].cancel();
+    }
+  }
+}