diff --git a/backend/src/baserow/contrib/database/table/handler.py b/backend/src/baserow/contrib/database/table/handler.py
index 5a9d69576..5d71947b5 100644
--- a/backend/src/baserow/contrib/database/table/handler.py
+++ b/backend/src/baserow/contrib/database/table/handler.py
@@ -26,12 +26,7 @@ from baserow.contrib.database.views.handler import ViewHandler
 from baserow.contrib.database.views.view_types import GridViewType
 from baserow.core.registries import application_type_registry
 from baserow.core.trash.handler import TrashHandler
-from baserow.core.utils import (
-    ChildProgressBuilder,
-    Progress,
-    find_unused_name,
-    split_ending_number,
-)
+from baserow.core.utils import ChildProgressBuilder, Progress, find_unused_name
 
 from .constants import TABLE_CREATION
 from .exceptions import (
@@ -410,8 +405,7 @@ class TableHandler:
         """
 
         existing_tables_names = list(database.table_set.values_list("name", flat=True))
-        name, _ = split_ending_number(proposed_name)
-        return find_unused_name([name], existing_tables_names, max_length=255)
+        return find_unused_name([proposed_name], existing_tables_names, max_length=255)
 
     def _create_related_link_fields_in_existing_tables_to_import(
         self, serialized_table: Dict[str, Any], id_mapping: Dict[str, Any]
@@ -476,18 +470,20 @@ class TableHandler:
         if not isinstance(table, Table):
             raise ValueError("The table is not an instance of Table")
 
-        progress = ChildProgressBuilder.build(progress_builder, child_total=2)
+        start_progress, export_progress, import_progress = 10, 30, 60
+        progress = ChildProgressBuilder.build(progress_builder, child_total=100)
+        progress.increment(by=start_progress)
 
         database = table.database
         database.group.has_user(user, raise_error=True)
         database_type = application_type_registry.get_by_model(database)
 
         serialized_tables = database_type.export_tables_serialized([table])
-        progress.increment()
 
         # Set a unique name for the table to import back as a new one.
         exported_table = serialized_tables[0]
         exported_table["name"] = self.find_unused_table_name(database, table.name)
+        exported_table["order"] = Table.get_last_order(database)
 
         id_mapping: Dict[str, Any] = {"database_tables": {}}
 
@@ -496,13 +492,17 @@ class TableHandler:
                 exported_table, id_mapping
             )
         )
+        progress.increment(by=export_progress)
+
         imported_tables = database_type.import_tables_serialized(
             database,
             [exported_table],
             id_mapping,
             external_table_fields_to_import=link_fields_to_import_to_existing_tables,
+            progress_builder=progress.create_child_builder(
+                represents_progress=import_progress
+            ),
         )
-        progress.increment()
 
         new_table_clone = imported_tables[0]
 
diff --git a/backend/src/baserow/contrib/database/table/job_types.py b/backend/src/baserow/contrib/database/table/job_types.py
index 5da83bf7a..6ed19f8d5 100644
--- a/backend/src/baserow/contrib/database/table/job_types.py
+++ b/backend/src/baserow/contrib/database/table/job_types.py
@@ -53,7 +53,9 @@ class DuplicateTableJobType(JobType):
         new_table_clone = action_type_registry.get_by_type(DuplicateTableActionType).do(
             job.user,
             job.original_table,
-            progress.create_child_builder(represents_progress=progress.total),
+            progress_builder=progress.create_child_builder(
+                represents_progress=progress.total
+            ),
         )
 
         # update the job with the new duplicated table
diff --git a/backend/src/baserow/core/handler.py b/backend/src/baserow/core/handler.py
index b9b176c8e..6dcb8efe6 100644
--- a/backend/src/baserow/core/handler.py
+++ b/backend/src/baserow/core/handler.py
@@ -847,25 +847,32 @@ class CoreHandler:
         group = application.group
         group.has_user(user, raise_error=True)
 
-        progress = ChildProgressBuilder.build(progress_builder, child_total=2)
+        start_progress, export_progress, import_progress = 10, 30, 60
+        progress = ChildProgressBuilder.build(progress_builder, child_total=100)
+        progress.increment(by=start_progress)
 
         # export the application
         specific_application = application.specific
         application_type = application_type_registry.get_by_model(specific_application)
         serialized = application_type.export_serialized(specific_application)
-        progress.increment()
+        progress.increment(by=export_progress)
 
         # Set a new unique name for the new application
         serialized["name"] = self.find_unused_application_name(
             group.id, serialized["name"]
         )
+        serialized["order"] = application_type.model_class.get_last_order(group)
 
         # import it back as a new application
         id_mapping: Dict[str, Any] = {}
         new_application_clone = application_type.import_serialized(
-            group, serialized, id_mapping
+            group,
+            serialized,
+            id_mapping,
+            progress_builder=progress.create_child_builder(
+                represents_progress=import_progress
+            ),
         )
-        progress.increment()
 
         # broadcast the application_created signal
         application_created.send(
diff --git a/changelog.md b/changelog.md
index 1918378ae..bdab18bc4 100644
--- a/changelog.md
+++ b/changelog.md
@@ -45,6 +45,7 @@ For example:
 * Add API token authentication support to multipart and via-URL file uploads. [#255](https://gitlab.com/bramw/baserow/-/issues/255)
 * Add a rich preview while importing data to an existing table. [#1120](https://gitlab.com/bramw/baserow/-/issues/1120)
 * Add env vars for controlling which URLs and IPs webhooks are allowed to use. [#931](https://gitlab.com/bramw/baserow/-/issues/931)
+* Show database and table duplication progress in the left sidebar. [#1059](https://gitlab.com/bramw/baserow/-/issues/1059)
 
 ### Bug Fixes
 * Resolve circular dependency in `FieldWithFiltersAndSortsSerializer` [#1113](https://gitlab.com/bramw/baserow/-/issues/1113)
diff --git a/web-frontend/modules/core/assets/scss/components/tree.scss b/web-frontend/modules/core/assets/scss/components/tree.scss
index 40a058b04..27cc7c016 100644
--- a/web-frontend/modules/core/assets/scss/components/tree.scss
+++ b/web-frontend/modules/core/assets/scss/components/tree.scss
@@ -10,6 +10,10 @@
   }
 }
 
+.tree:not(:last-child) {
+  margin-bottom: 3px;
+}
+
 .tree__item {
   @extend %first-last-no-margin;
 
@@ -40,6 +44,11 @@
   padding: 0 6px;
   border-radius: 3px;
 
+  &.tree__action--disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+  }
+
   &:not(.tree__action--disabled):hover {
     background-color: $color-neutral-100;
   }
@@ -79,6 +88,13 @@
   }
 }
 
+.tree__progress_percentage {
+  display: inline-block;
+  position: absolute;
+  right: 35px;
+  color: $color-primary-500;
+}
+
 .tree__icon {
   @extend %tree__size;
 
@@ -135,6 +151,14 @@
   }
 }
 
+.tree__subs:not(:last-of-type) {
+  padding: 0;
+
+  & .tree__sub:last-child::before {
+    height: 28px;
+  }
+}
+
 .tree__sub-link {
   @extend %tree_sub-size;
   @extend %ellipsis;
@@ -154,6 +178,23 @@
   }
 }
 
+.tree__sub-link--loading {
+  @include loading(14px);
+  @include absolute(9px, 9px, auto, auto);
+}
+
+.tree__sub-link--disabled {
+  opacity: 0.6;
+  cursor: not-allowed;
+
+  &:hover {
+    color: $color-primary-900;
+    opacity: 0.6;
+    text-decoration: none;
+    cursor: not-allowed;
+  }
+}
+
 .tree__sub-add {
   display: inline-block;
   margin: 0 0 10px 10px;
diff --git a/web-frontend/modules/core/components/sidebar/Sidebar.vue b/web-frontend/modules/core/components/sidebar/Sidebar.vue
index b0fead6b5..5985f33b3 100644
--- a/web-frontend/modules/core/components/sidebar/Sidebar.vue
+++ b/web-frontend/modules/core/components/sidebar/Sidebar.vue
@@ -179,6 +179,15 @@
                 :group="selectedGroup"
               ></component>
             </ul>
+            <ul v-if="pendingJobs.length" class="tree">
+              <component
+                :is="getPendingJobComponent(job)"
+                v-for="job in pendingJobs"
+                :key="job.id"
+                :job="job"
+              >
+              </component>
+            </ul>
             <li class="sidebar__new-wrapper">
               <a
                 ref="createApplicationContextLink"
@@ -329,6 +338,13 @@ export default {
         .map((plugin) => plugin.getSidebarMainMenuComponent())
         .filter((component) => component !== null)
     },
+    pendingJobs() {
+      return this.$store.getters['job/getAll'].filter((job) =>
+        this.$registry
+          .get('job', job.type)
+          .isJobPartOfGroup(job, this.selectedGroup)
+      )
+    },
     /**
      * Indicates whether the current user is visiting an admin page.
      */
@@ -358,6 +374,9 @@ export default {
         .get('application', application.type)
         .getSidebarComponent()
     },
+    getPendingJobComponent(job) {
+      return this.$registry.get('job', job.type).getSidebarComponent()
+    },
     logoff() {
       this.$store.dispatch('auth/logoff')
       this.$nuxt.$router.push({ name: 'login' })
diff --git a/web-frontend/modules/core/components/sidebar/SidebarApplication.vue b/web-frontend/modules/core/components/sidebar/SidebarApplication.vue
index 5e99a3f44..0596586b1 100644
--- a/web-frontend/modules/core/components/sidebar/SidebarApplication.vue
+++ b/web-frontend/modules/core/components/sidebar/SidebarApplication.vue
@@ -38,27 +38,18 @@
             <a @click="enableRename()">
               <i class="context__menu-icon fas fa-fw fa-pen"></i>
               {{
-                $t('sidebarApplication.renameApplication', {
+                $t('sidebarApplication.rename', {
                   type: application._.type.name.toLowerCase(),
                 })
               }}
             </a>
           </li>
           <li>
-            <a
-              :class="{
-                'context__menu-item--loading': duplicateLoading,
-                disabled: duplicateLoading || deleteLoading,
-              }"
-              @click="duplicateApplication()"
-            >
-              <i class="context__menu-icon fas fa-fw fa-copy"></i>
-              {{
-                $t('sidebarApplication.duplicateApplication', {
-                  type: application._.type.name.toLowerCase(),
-                })
-              }}
-            </a>
+            <SidebarDuplicateApplicationContextItem
+              :application="application"
+              :disabled="deleting"
+              @click="$refs.context.hide()"
+            ></SidebarDuplicateApplicationContextItem>
           </li>
           <li>
             <a @click="openSnapshots">
@@ -78,12 +69,12 @@
           </li>
           <li>
             <a
-              :class="{ 'context__menu-item--loading': deleteLoading }"
+              :class="{ 'context__menu-item--loading': deleting }"
               @click="deleteApplication()"
             >
               <i class="context__menu-icon fas fa-fw fa-trash"></i>
               {{
-                $t('sidebarApplication.deleteApplication', {
+                $t('sidebarApplication.delete', {
                   type: application._.type.name.toLowerCase(),
                 })
               }}
@@ -103,17 +94,18 @@
 </template>
 
 <script>
-import { mapGetters } from 'vuex'
 import { notifyIf } from '@baserow/modules/core/utils/error'
-import ApplicationService from '@baserow/modules/core/services/application'
-import jobProgress from '@baserow/modules/core/mixins/jobProgress'
+import SidebarDuplicateApplicationContextItem from '@baserow/modules/core/components/sidebar/SidebarDuplicateApplicationContextItem.vue'
 import TrashModal from '@baserow/modules/core/components/trash/TrashModal'
 import SnapshotsModal from '@baserow/modules/core/components/snapshots/SnapshotsModal'
 
 export default {
   name: 'SidebarApplication',
-  components: { TrashModal, SnapshotsModal },
-  mixins: [jobProgress],
+  components: {
+    TrashModal,
+    SidebarDuplicateApplicationContextItem,
+    SnapshotsModal,
+  },
   props: {
     application: {
       type: Object,
@@ -126,18 +118,9 @@ export default {
   },
   data() {
     return {
-      deleteLoading: false,
-      duplicateLoading: false,
+      deleting: false,
     }
   },
-  computed: {
-    ...mapGetters({
-      selectedTable: 'table/getSelected',
-    }),
-  },
-  beforeDestroy() {
-    this.stopPollIfRunning()
-  },
   methods: {
     setLoading(application, value) {
       this.$store.dispatch('application/setItemLoading', {
@@ -166,92 +149,12 @@ export default {
 
       this.setLoading(application, false)
     },
-    showError(title, message) {
-      this.$store.dispatch(
-        'notification/error',
-        { title, message },
-        { root: true }
-      )
-    },
-    // eslint-disable-next-line require-await
-    async onJobFailed() {
-      this.duplicateLoading = false
-      this.$refs.context.hide()
-      this.showError(
-        this.$t('clientHandler.notCompletedTitle'),
-        this.$t('clientHandler.notCompletedDescription')
-      )
-    },
-    // eslint-disable-next-line require-await
-    async onJobPollingError(error) {
-      this.duplicateLoading = false
-      this.$refs.context.hide()
-      notifyIf(error, 'application')
-    },
-    async onJobDone() {
-      const newApplicationId = this.job.duplicated_application.id
-      let newApplication
-      try {
-        newApplication = await this.$store.dispatch('application/fetch', {
-          applicationId: newApplicationId,
-        })
-      } catch (error) {
-        notifyIf(error, 'application')
-      } finally {
-        this.duplicateLoading = false
-        this.$refs.context.hide()
-      }
-
-      // find the matching table in the duplicated application if any
-      // otherwise just select the first table and show it
-      if (newApplication) {
-        if (newApplication.tables.length) {
-          let selectTable = newApplication.tables[0]
-          const originalSelectedTable = this.selectedTable
-          if (originalSelectedTable) {
-            for (const table of newApplication.tables) {
-              if (table.name === originalSelectedTable.name) {
-                selectTable = table
-                break
-              }
-            }
-          }
-          this.$nuxt.$router.push({
-            name: 'database-table',
-            params: {
-              databaseId: newApplication.id,
-              tableId: selectTable.id,
-            },
-          })
-        } else {
-          this.$emit('selected', newApplication)
-        }
-      }
-    },
-    async duplicateApplication() {
-      if (this.duplicateLoading || this.deleteLoading) {
-        return
-      }
-
-      const application = this.application
-      this.duplicateLoading = true
-
-      try {
-        const { data: job } = await ApplicationService(
-          this.$client
-        ).asyncDuplicate(application.id)
-        this.startJobPoller(job)
-      } catch (error) {
-        this.duplicateLoading = false
-        notifyIf(error, 'application')
-      }
-    },
     async deleteApplication() {
-      if (this.deleteLoading) {
+      if (this.deleting) {
         return
       }
 
-      this.deleteLoading = true
+      this.deleting = true
 
       try {
         await this.$store.dispatch('application/delete', this.application)
@@ -263,7 +166,7 @@ export default {
         notifyIf(error, 'application')
       }
 
-      this.deleteLoading = false
+      this.deleting = false
     },
     showApplicationTrashModal() {
       this.$refs.context.hide()
diff --git a/web-frontend/modules/core/components/sidebar/SidebarApplicationPendingJob.vue b/web-frontend/modules/core/components/sidebar/SidebarApplicationPendingJob.vue
new file mode 100644
index 000000000..11f0b661c
--- /dev/null
+++ b/web-frontend/modules/core/components/sidebar/SidebarApplicationPendingJob.vue
@@ -0,0 +1,36 @@
+<template>
+  <li class="tree__item tree__item--loading">
+    <div class="tree__action tree__action--disabled">
+      <a class="tree__link">
+        <i
+          class="tree__icon tree__icon--type fas"
+          :class="'fa-' + jobIconClass"
+        ></i>
+        {{ jobSidebarText }}
+        <div class="tree__progress_percentage">
+          {{ job.progress_percentage }} %
+        </div>
+      </a>
+    </div>
+  </li>
+</template>
+
+<script>
+export default {
+  name: 'SidebarApplicationPendingJob',
+  props: {
+    job: {
+      type: Object,
+      required: true,
+    },
+  },
+  computed: {
+    jobSidebarText() {
+      return this.$registry.get('job', this.job.type).getSidebarText(this.job)
+    },
+    jobIconClass() {
+      return this.$registry.get('job', this.job.type).getIconClass()
+    },
+  },
+}
+</script>
diff --git a/web-frontend/modules/core/components/sidebar/SidebarDuplicateApplicationContextItem.vue b/web-frontend/modules/core/components/sidebar/SidebarDuplicateApplicationContextItem.vue
new file mode 100644
index 000000000..c1e3bf503
--- /dev/null
+++ b/web-frontend/modules/core/components/sidebar/SidebarDuplicateApplicationContextItem.vue
@@ -0,0 +1,98 @@
+<template>
+  <a
+    :class="{
+      'context__menu-item--loading': duplicating,
+      disabled: disabled || duplicating,
+    }"
+    @click="duplicateApplication()"
+  >
+    <i class="context__menu-icon fas fa-fw fa-copy"></i>
+    {{
+      $t('sidebarApplication.duplicate', {
+        type: application._.type.name.toLowerCase(),
+      })
+    }}
+  </a>
+</template>
+
+<script>
+import { notifyIf } from '@baserow/modules/core/utils/error'
+import ApplicationService from '@baserow/modules/core/services/application'
+import jobProgress from '@baserow/modules/core/mixins/jobProgress'
+
+export default {
+  name: 'SidebarDuplicateApplicationContextItem',
+  mixins: [jobProgress],
+  props: {
+    application: {
+      type: Object,
+      required: true,
+    },
+    disabled: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      duplicating: false,
+    }
+  },
+  methods: {
+    showError(title, message) {
+      this.$store.dispatch(
+        'notification/error',
+        { title, message },
+        { root: true }
+      )
+    },
+    // eslint-disable-next-line require-await
+    async onJobFailed() {
+      await this.$store.dispatch('job/forceUpdate', {
+        job: this.job,
+        data: this.job,
+      })
+
+      this.duplicating = false
+    },
+    async onJobPollingError(error) {
+      await this.$store.dispatch('job/forceDelete', this.job)
+      this.duplicating = false
+      notifyIf(error, 'application')
+    },
+    async onJobUpdated() {
+      await this.$store.dispatch('job/forceUpdate', {
+        job: this.job,
+        data: this.job,
+      })
+    },
+    // eslint-disable-next-line require-await
+    async onJobDone() {
+      await this.$store.dispatch('job/forceUpdate', {
+        job: this.job,
+        data: this.job,
+      })
+      this.duplicating = false
+    },
+    async duplicateApplication() {
+      if (this.duplicating || this.disabled) {
+        return
+      }
+
+      this.duplicating = true
+      try {
+        const { data: job } = await ApplicationService(
+          this.$client
+        ).asyncDuplicate(this.application.id)
+        this.startJobPoller(job)
+        this.$store.dispatch('job/forceCreate', job)
+      } catch (error) {
+        this.duplicating = false
+        notifyIf(error, 'application')
+      }
+      this.$emit('click')
+    },
+  },
+}
+</script>
diff --git a/web-frontend/modules/core/jobTypes.js b/web-frontend/modules/core/jobTypes.js
new file mode 100644
index 000000000..c1edb3365
--- /dev/null
+++ b/web-frontend/modules/core/jobTypes.js
@@ -0,0 +1,159 @@
+import { notifyIf } from '@baserow/modules/core/utils/error'
+import { Registerable } from '@baserow/modules/core/registry'
+import SidebarApplicationPendingJob from '@baserow/modules/core/components/sidebar/SidebarApplicationPendingJob'
+
+/**
+ * The job type base class that can be extended when creating a plugin
+ * for the frontend.
+ */
+export class JobType extends Registerable {
+  /**
+   * The font awesome 5 icon name that is used as convenience for the user to
+   * recognize certain job types. If you for example want the database
+   * icon, you must return 'database' here. This will result in the classname
+   * 'fas fa-database'.
+   */
+  getIconClass() {
+    return null
+  }
+
+  /**
+   * A human readable name of the job type.
+   */
+  getName() {
+    return null
+  }
+
+  /**
+   * Returns the text to show in the sidebar when the job is running.
+   */
+  getSidebarText(job) {
+    return ''
+  }
+
+  /**
+   * The sidebar component will be rendered in the sidebar if the application is
+   * in the selected group. All the applications of a group are listed in the
+   * sidebar and this component should give the user the possibility to select
+   * that application.
+   */
+  getSidebarComponent() {
+    return null
+  }
+
+  constructor(...args) {
+    super(...args)
+    this.type = this.getType()
+
+    if (this.type === null) {
+      throw new Error('The type name of an application type must be set.')
+    }
+    if (this.name === null) {
+      throw new Error('The name of an application type must be set.')
+    }
+  }
+
+  /**
+   * Every time a fresh job object is fetched from the backend, it will
+   * be populated, this is the moment to update some values. Because each
+   * jobType can have unique properties, they might need to be populated.
+   * This method can be overwritten in order the populate the correct values.
+   */
+  populate(job) {
+    return job
+  }
+
+  /**
+   * Returns true if the specific job is part of the group
+   */
+  isJobPartOfGroup(job, group) {
+    return false
+  }
+
+  /**
+   * Returns true if the specific job is part of the application
+   */
+  isJobPartOfApplication(job, application) {
+    return false
+  }
+
+  /**
+   * When an application is deleted it could be that an action should be taken,
+   * like redirect the user to another page. This method is called when application
+   * of this type is deleted.
+   */
+  beforeDelete(job) {}
+
+  /**
+   * Before the application values are updated, they can be modified here. This
+   * might be needed because providing certain values could break the update.
+   */
+  async beforeUpdate(job, data) {}
+
+  async afterUpdate(job, data) {
+    if (job.state === 'finished') {
+      await this.onJobDone(job, data)
+    } else if (job.state === 'failed') {
+      await this.onJobFailed(job, data)
+    }
+  }
+
+  async onJobDone(job) {}
+  async onJobFailed(job) {}
+}
+
+export class DuplicateApplicationJobType extends JobType {
+  static getType() {
+    return 'duplicate_application'
+  }
+
+  getIconClass() {
+    // TODO: This should be moved to a registry and in the database module.
+    return 'database'
+  }
+
+  getName() {
+    return 'duplicateApplication'
+  }
+
+  getSidebarText(job) {
+    const { i18n } = this.app
+    return i18n.t('duplicateApplicationJobType.duplicating') + '... '
+  }
+
+  getSidebarComponent() {
+    return SidebarApplicationPendingJob
+  }
+
+  isJobPartOfGroup(job, group) {
+    return job.original_application.group.id === group.id
+  }
+
+  async onJobDone(job) {
+    const { i18n, store } = this.app
+    const application = job.duplicated_application
+    try {
+      await store.dispatch('application/fetch', application.id)
+      store.dispatch('notification/info', {
+        title: i18n.t('duplicateApplicationJobType.duplicatedTitle'),
+        message: application.name,
+      })
+      await store.dispatch('job/forceDelete', job)
+    } catch (error) {
+      notifyIf(error, 'application')
+    }
+  }
+
+  async onJobFailed(job) {
+    const { i18n, store } = this.app
+    await store.dispatch(
+      'notification/error',
+      {
+        title: i18n.t('clientHandler.notCompletedTitle'),
+        message: i18n.t('clientHandler.notCompletedDescription'),
+      },
+      { root: true }
+    )
+    await this.app.store.dispatch('job/forceDelete', job)
+  }
+}
diff --git a/web-frontend/modules/core/locales/en.json b/web-frontend/modules/core/locales/en.json
index 1178730ed..a03b90fc1 100644
--- a/web-frontend/modules/core/locales/en.json
+++ b/web-frontend/modules/core/locales/en.json
@@ -7,12 +7,16 @@
         "label": "Copied!"
     },
     "sidebarApplication": {
-        "renameApplication": "Rename {type}",
-        "duplicateApplication": "Duplicate {type}",
+        "rename": "Rename",
+        "duplicate": "Duplicate",
         "viewTrash": "View trash",
-        "deleteApplication": "Delete {type}",
+        "delete": "Delete",
         "snapshots": "Snapshots"
     },
+    "duplicateApplicationJobType": {
+        "duplicating": "Duplicating",
+        "duplicatedTitle": "Application duplicated"
+    },
     "sidebar": {
         "createGroup": "Create group",
         "inviteOthers": "Invite others",
@@ -397,4 +401,4 @@
         "monthsAgo": "0 months ago | 1 month ago | {n} months ago",
         "yearsAgo": "0 years ago | 1 year ago | {n} years ago"
     }
-}
+}
\ No newline at end of file
diff --git a/web-frontend/modules/core/mixins/jobProgress.js b/web-frontend/modules/core/mixins/jobProgress.js
index 73e69acd5..5be505c2a 100644
--- a/web-frontend/modules/core/mixins/jobProgress.js
+++ b/web-frontend/modules/core/mixins/jobProgress.js
@@ -4,35 +4,34 @@ import JobService from '@baserow/modules/core/services/job'
  * To use this mixin you need to create the following methods on your component:
  * - `getCustomHumanReadableJobState(state)` returns the human readable message your
  *   custom state values.
- * - onJobDone() (optional) is called when the job is finished
- * - onJobFailed() (optional) is called if the job failed
- * - onJobPollingError(error) (optional) is there is an exception during the job polling
+ * - onJobUpdated() (optional) is called during the polling for not finished jobs.
+ * - onJobDone() (optional) is called if the job successfully finishes.
+ * - onJobFailed() (optional) is called if the job fails.
+ * - onJobPollingError(error) (optional) is called if the polling fails.
  *
  */
 export default {
   data() {
     return {
       job: null,
-      pollInterval: null,
+      nextPollTimeout: null,
+      pollTimeoutId: null,
     }
   },
   computed: {
-    jobIsRunning() {
-      return (
-        this.job !== null && !['failed', 'finished'].includes(this.job.state)
-      )
-    },
-    jobIsFinished() {
-      return (
-        this.job !== null && ['failed', 'finished'].includes(this.job.state)
-      )
-    },
     jobHasSucceeded() {
       return this.job?.state === 'finished'
     },
     jobHasFailed() {
       return this.job?.state === 'failed'
     },
+    jobIsFinished() {
+      return this.jobHasSucceeded || this.jobHasFailed
+    },
+    jobIsRunning() {
+      return this.job !== null && !this.jobIsFinished
+    },
+
     jobHumanReadableState() {
       if (this.job === null) {
         return ''
@@ -57,35 +56,46 @@ export default {
      */
     startJobPoller(job) {
       this.job = job
-      this.pollInterval = setTimeout(this.getLatestJobInfo, 1000)
+      this.nextPollTimeout = 200
+      this.pollTimeoutId = setTimeout(
+        this.getLatestJobInfo,
+        this.nextPollTimeout
+      )
     },
     async getLatestJobInfo() {
       try {
-        const { data } = await JobService(this.$client).get(this.job.id)
-        this.job = data
-        if (this.jobHasFailed) {
-          if (this.onJobFailed) {
+        const { data: job } = await JobService(this.$client).get(this.job.id)
+        this.job = job
+        if (job.state === 'failed') {
+          if (typeof this.onJobFailed === 'function') {
             await this.onJobFailed()
           }
-        } else if (!this.jobIsRunning) {
-          if (this.onJobDone) {
+        } else if (job.state === 'finished') {
+          if (typeof this.onJobDone === 'function') {
             await this.onJobDone()
           }
         } else {
-          // job unfinished, set the next polling timeout
-          this.pollInterval = setTimeout(this.getLatestJobInfo, 1000)
+          // job unfinished, keep polling
+          if (typeof this.onJobUpdated === 'function') {
+            await this.onJobUpdated()
+          }
+          this.nextPollTimeout = Math.min(this.nextPollTimeout * 1.5, 2500)
+          this.pollTimeoutId = setTimeout(
+            this.getLatestJobInfo,
+            this.nextPollTimeout
+          )
         }
       } catch (error) {
-        if (this.onJobPollingError) {
+        if (typeof this.onJobPollingError === 'function') {
           this.onJobPollingError(error)
         }
         this.job = null
       }
     },
     stopPollIfRunning() {
-      if (this.pollInterval) {
-        clearTimeout(this.pollInterval)
-        this.pollInterval = null
+      if (this.pollTimeoutId) {
+        clearTimeout(this.pollTimeoutId)
+        this.pollTimeoutId = null
       }
     },
   },
diff --git a/web-frontend/modules/core/plugin.js b/web-frontend/modules/core/plugin.js
index 560b9871b..1a03632bc 100644
--- a/web-frontend/modules/core/plugin.js
+++ b/web-frontend/modules/core/plugin.js
@@ -1,6 +1,7 @@
 import Vue from 'vue'
 
 import { Registry } from '@baserow/modules/core/registry'
+import { DuplicateApplicationJobType } from '@baserow/modules/core/jobTypes'
 
 import {
   AccountSettingsType,
@@ -17,6 +18,7 @@ import settingsStore from '@baserow/modules/core/store/settings'
 import applicationStore from '@baserow/modules/core/store/application'
 import authStore from '@baserow/modules/core/store/auth'
 import groupStore from '@baserow/modules/core/store/group'
+import jobStore from '@baserow/modules/core/store/job'
 import notificationStore from '@baserow/modules/core/store/notification'
 import sidebarStore from '@baserow/modules/core/store/sidebar'
 import undoRedoStore from '@baserow/modules/core/store/undoRedo'
@@ -46,6 +48,7 @@ export default (context, inject) => {
   const registry = new Registry()
   registry.registerNamespace('plugin')
   registry.registerNamespace('application')
+  registry.registerNamespace('job')
   registry.registerNamespace('view')
   registry.registerNamespace('field')
   registry.registerNamespace('settings')
@@ -64,8 +67,11 @@ export default (context, inject) => {
   store.registerModule('settings', settingsStore)
   store.registerModule('application', applicationStore)
   store.registerModule('auth', authStore)
+  store.registerModule('job', jobStore)
   store.registerModule('group', groupStore)
   store.registerModule('notification', notificationStore)
   store.registerModule('sidebar', sidebarStore)
   store.registerModule('undoRedo', undoRedoStore)
+
+  registry.register('job', new DuplicateApplicationJobType(context))
 }
diff --git a/web-frontend/modules/core/store/application.js b/web-frontend/modules/core/store/application.js
index 4fc446587..b237b0998 100644
--- a/web-frontend/modules/core/store/application.js
+++ b/web-frontend/modules/core/store/application.js
@@ -42,19 +42,23 @@ export const mutations = {
   },
   UPDATE_ITEM(state, { id, values }) {
     const index = state.items.findIndex((item) => item.id === id)
-    Object.assign(state.items[index], state.items[index], values)
+    if (index !== -1) {
+      Object.assign(state.items[index], state.items[index], values)
+    }
   },
   ORDER_ITEMS(state, { group, order }) {
     state.items
       .filter((item) => item.group.id === group.id)
       .forEach((item) => {
         const index = order.findIndex((value) => value === item.id)
-        item.order = index === -1 ? 0 : index + 1
+        item.order = index === -1 ? undefined : index + 1
       })
   },
   DELETE_ITEM(state, id) {
     const index = state.items.findIndex((item) => item.id === id)
-    state.items.splice(index, 1)
+    if (index !== -1) {
+      state.items.splice(index, 1)
+    }
   },
   DELETE_ITEMS_FOR_GROUP(state, groupId) {
     state.items = state.items.filter((app) => app.group.id !== groupId)
@@ -111,9 +115,8 @@ export const actions = {
   /**
    * Fetches one application for the authenticated user.
    */
-  async fetch({ commit, dispatch }, { applicationId }) {
+  async fetch({ commit, dispatch }, applicationId) {
     commit('SET_LOADING', true)
-
     try {
       const { data } = await ApplicationService(this.$client).get(applicationId)
       dispatch('forceCreate', data)
@@ -246,8 +249,9 @@ export const actions = {
   /**
    * Forcefully delete an item in the store without making a call to the server.
    */
-  forceDelete({ commit }, application) {
+  forceDelete({ commit, dispatch }, application) {
     const type = this.$registry.get('application', application.type)
+    dispatch('job/deleteForApplication', application, { root: true })
     type.delete(application, this)
     commit('DELETE_ITEM', application.id)
   },
diff --git a/web-frontend/modules/core/store/group.js b/web-frontend/modules/core/store/group.js
index 815bdd469..66e311429 100644
--- a/web-frontend/modules/core/store/group.js
+++ b/web-frontend/modules/core/store/group.js
@@ -226,6 +226,7 @@ export const actions = {
    * example a Table is open that has been deleted because the group has been deleted.
    */
   forceDelete({ commit, dispatch, rootGetters }, group) {
+    dispatch('job/deleteForGroup', group, { root: true })
     const applications = rootGetters['application/getAllOfGroup'](group)
     applications.forEach((application) => {
       return dispatch('application/forceDelete', application, { root: true })
diff --git a/web-frontend/modules/core/store/job.js b/web-frontend/modules/core/store/job.js
new file mode 100644
index 000000000..3803319c8
--- /dev/null
+++ b/web-frontend/modules/core/store/job.js
@@ -0,0 +1,90 @@
+export function populateJob(job, registry) {
+  const type = registry.get('job', job.type)
+  return type.populate(job)
+}
+
+export const state = () => ({
+  items: [],
+})
+
+export const mutations = {
+  ADD_ITEM(state, item) {
+    state.items.push(item)
+  },
+  UPDATE_ITEM(state, { id, values }) {
+    const index = state.items.findIndex((item) => item.id === id)
+    if (index !== -1) {
+      Object.assign(state.items[index], state.items[index], values)
+    }
+  },
+  DELETE_ITEM(state, id) {
+    const index = state.items.findIndex((item) => item.id === id)
+    if (index !== -1) {
+      state.items.splice(index, 1)
+    }
+  },
+}
+
+export const actions = {
+  /**
+   * Forcefully create an item in the store without making a call to the server.
+   */
+  forceCreate({ commit }, job) {
+    populateJob(job, this.$registry)
+    commit('ADD_ITEM', job)
+  },
+  /**
+   * Forcefully update an item in the store without making a call to the server.
+   */
+  async forceUpdate({ commit }, { job, data }) {
+    const type = this.$registry.get('job', job.type)
+    await type.beforeUpdate(job, data)
+    commit('UPDATE_ITEM', { id: job.id, values: data })
+    await type.afterUpdate(job, data)
+  },
+  /**
+   * Forcefully delete an item in the store without making a call to the server.
+   */
+  forceDelete({ commit }, job) {
+    const type = this.$registry.get('job', job.type)
+    type.beforeDelete(job, this)
+    commit('DELETE_ITEM', job.id)
+  },
+  /**
+   * Deletes all the jobs belonging to the given group.
+   */
+  deleteForGroup({ commit, state }, group) {
+    const jobs = state.items.filter((job) =>
+      this.$registry.get('job', job.type).isJobPartOfGroup(job, group)
+    )
+    jobs.forEach((job) => commit('DELETE_ITEM', job.id))
+  },
+  /**
+   * Deletes all the jobs belonging to the given application.
+   */
+  deleteForApplication({ commit, state }, application) {
+    const jobs = state.items.filter((job) =>
+      this.$registry
+        .get('job', job.type)
+        .isJobPartOfApplication(job, application)
+    )
+    jobs.forEach((job) => commit('DELETE_ITEM', job.id))
+  },
+}
+
+export const getters = {
+  get: (state) => (id) => {
+    return state.items.find((item) => item.id === id)
+  },
+  getAll: (state) => {
+    return state.items
+  },
+}
+
+export default {
+  namespaced: true,
+  state,
+  getters,
+  actions,
+  mutations,
+}
diff --git a/web-frontend/modules/database/components/sidebar/Sidebar.vue b/web-frontend/modules/database/components/sidebar/Sidebar.vue
index d4be25785..36b7839af 100644
--- a/web-frontend/modules/database/components/sidebar/Sidebar.vue
+++ b/web-frontend/modules/database/components/sidebar/Sidebar.vue
@@ -35,6 +35,14 @@
           :table="table"
         ></SidebarItem>
       </ul>
+      <ul v-if="pendingJobs.length" class="tree__subs">
+        <SidebarItemPendingJob
+          v-for="job in pendingJobs"
+          :key="job.id"
+          :job="job"
+        >
+        </SidebarItemPendingJob>
+      </ul>
       <a class="tree__sub-add" @click="$refs.importFileModal.show()">
         <i class="fas fa-plus"></i>
         {{ $t('sidebar.createTable') }}
@@ -47,6 +55,7 @@
 <script>
 import { notifyIf } from '@baserow/modules/core/utils/error'
 import SidebarItem from '@baserow/modules/database/components/sidebar/SidebarItem'
+import SidebarItemPendingJob from '@baserow/modules/database/components/sidebar/SidebarItemPendingJob'
 import ImportFileModal from '@baserow/modules/database/components/table/ImportFileModal'
 import SidebarApplication from '@baserow/modules/core/components/sidebar/SidebarApplication'
 
@@ -55,6 +64,7 @@ export default {
   components: {
     SidebarApplication,
     SidebarItem,
+    SidebarItemPendingJob,
     ImportFileModal,
   },
   props: {
@@ -73,6 +83,13 @@ export default {
         .map((table) => table)
         .sort((a, b) => a.order - b.order)
     },
+    pendingJobs() {
+      return this.$store.getters['job/getAll'].filter((job) =>
+        this.$registry
+          .get('job', job.type)
+          .isJobPartOfApplication(job, this.application)
+      )
+    },
   },
   methods: {
     async selected(application) {
diff --git a/web-frontend/modules/database/components/sidebar/SidebarItem.vue b/web-frontend/modules/database/components/sidebar/SidebarItem.vue
index cb15a3a1d..1d49891c1 100644
--- a/web-frontend/modules/database/components/sidebar/SidebarItem.vue
+++ b/web-frontend/modules/database/components/sidebar/SidebarItem.vue
@@ -47,10 +47,7 @@
             :database="database"
             :table="table"
             :disabled="deleteLoading"
-            @table-duplicated="
-              $refs.context.hide()
-              selectTable(database, $event.table)
-            "
+            @click="$refs.context.hide()"
           ></SidebarDuplicateTableContextItem>
         </li>
         <li>
diff --git a/web-frontend/modules/database/components/sidebar/SidebarItemPendingJob.vue b/web-frontend/modules/database/components/sidebar/SidebarItemPendingJob.vue
new file mode 100644
index 000000000..6cae5c448
--- /dev/null
+++ b/web-frontend/modules/database/components/sidebar/SidebarItemPendingJob.vue
@@ -0,0 +1,28 @@
+<template>
+  <li class="tree__sub">
+    <a class="tree__sub-link tree__sub-link--disabled">
+      {{ jobSidebarText }}
+      <div class="tree__progress_percentage">
+        {{ job.progress_percentage }} %
+      </div>
+    </a>
+    <div class="tree__sub-link--loading"></div>
+  </li>
+</template>
+
+<script>
+export default {
+  name: 'SidebarItemPendingJob',
+  props: {
+    job: {
+      type: Object,
+      required: true,
+    },
+  },
+  computed: {
+    jobSidebarText() {
+      return this.$registry.get('job', this.job.type).getSidebarText(this.job)
+    },
+  },
+}
+</script>
diff --git a/web-frontend/modules/database/components/sidebar/table/SidebarDuplicateTableContextItem.vue b/web-frontend/modules/database/components/sidebar/table/SidebarDuplicateTableContextItem.vue
index d5d80a2aa..22623c1e2 100644
--- a/web-frontend/modules/database/components/sidebar/table/SidebarDuplicateTableContextItem.vue
+++ b/web-frontend/modules/database/components/sidebar/table/SidebarDuplicateTableContextItem.vue
@@ -1,8 +1,8 @@
 <template>
   <a
     :class="{
-      'context__menu-item--loading': loading,
-      disabled: disabled || loading,
+      'context__menu-item--loading': duplicating,
+      disabled: disabled || duplicating,
     }"
     @click="duplicateTable()"
   >
@@ -36,7 +36,7 @@ export default {
   },
   data() {
     return {
-      loading: false,
+      duplicating: false,
     }
   },
   methods: {
@@ -47,44 +47,48 @@ export default {
         { root: true }
       )
     },
-    // eslint-disable-next-line require-await
     async onJobFailed() {
-      this.loading = false
-      this.showError(
-        this.$t('clientHandler.notCompletedTitle'),
-        this.$t('clientHandler.notCompletedDescription')
-      )
+      await this.$store.dispatch('job/forceUpdate', {
+        job: this.job,
+        data: this.job,
+      })
+      this.duplicating = false
     },
-    // eslint-disable-next-line require-await
     async onJobPollingError(error) {
-      this.loading = false
+      await this.$store.dispatch('job/forceDelete', this.job)
+      this.duplicating = false
       notifyIf(error, 'table')
     },
-    async onJobDone() {
-      const database = this.database
-      const table = this.job.duplicated_table
-      await this.$store.dispatch('table/forceCreate', {
-        database,
-        data: table,
+    async onJobUpdated() {
+      await this.$store.dispatch('job/forceUpdate', {
+        job: this.job,
+        data: this.job,
       })
-      this.loading = false
-      this.$emit('table-duplicated', { table })
+    },
+    async onJobDone() {
+      await this.$store.dispatch('job/forceUpdate', {
+        job: this.job,
+        data: this.job,
+      })
+      this.duplicating = false
     },
     async duplicateTable() {
-      if (this.loading || this.disabled) {
+      if (this.duplicating || this.disabled) {
         return
       }
 
-      this.loading = true
+      this.duplicating = true
       try {
         const { data: job } = await TableService(this.$client).asyncDuplicate(
           this.table.id
         )
         this.startJobPoller(job)
+        this.$store.dispatch('job/forceCreate', job)
       } catch (error) {
-        this.loading = false
+        this.duplicating = false
         notifyIf(error, 'table')
       }
+      this.$emit('click')
     },
   },
 }
diff --git a/web-frontend/modules/database/jobTypes.js b/web-frontend/modules/database/jobTypes.js
new file mode 100644
index 000000000..775a33013
--- /dev/null
+++ b/web-frontend/modules/database/jobTypes.js
@@ -0,0 +1,61 @@
+import { JobType } from '@baserow/modules/core/jobTypes'
+
+import SidebarItemPendingJob from '@baserow/modules/database/components/sidebar/SidebarItemPendingJob.vue'
+
+export class DuplicateTableJobType extends JobType {
+  static getType() {
+    return 'duplicate_table'
+  }
+
+  getName() {
+    return 'duplicateTable'
+  }
+
+  getSidebarText(job) {
+    const { i18n } = this.app
+    return i18n.t('duplicateTableJobType.duplicating') + '...'
+  }
+
+  getSidebarComponent() {
+    return SidebarItemPendingJob
+  }
+
+  isJobPartOfApplication(job, application) {
+    return job.original_table.database_id === application.id
+  }
+
+  async onJobFailed(job) {
+    const { i18n, store } = this.app
+
+    store.dispatch(
+      'notification/error',
+      {
+        title: i18n.t('clientHandler.notCompletedTitle'),
+        message: i18n.t('clientHandler.notCompletedDescription'),
+      },
+      { root: true }
+    )
+    await store.dispatch('job/forceDelete', job)
+  }
+
+  async onJobDone(job) {
+    const { i18n, store } = this.app
+
+    const duplicatedTable = job.duplicated_table
+    const database = store.getters['application/get'](
+      duplicatedTable.database_id
+    )
+
+    await store.dispatch('table/forceCreate', {
+      database,
+      data: duplicatedTable,
+    })
+
+    store.dispatch('notification/info', {
+      title: i18n.t('duplicateTableJobType.duplicatedTitle'),
+      message: duplicatedTable.name,
+    })
+
+    store.dispatch('job/forceDelete', job)
+  }
+}
diff --git a/web-frontend/modules/database/locales/en.json b/web-frontend/modules/database/locales/en.json
index 9e7dac81b..9d6ffb6f9 100644
--- a/web-frontend/modules/database/locales/en.json
+++ b/web-frontend/modules/database/locales/en.json
@@ -71,6 +71,10 @@
     "sidebarItem": {
         "exportTable": "Export table"
     },
+    "duplicateTableJobType": {
+        "duplicating": "Duplicating",
+        "duplicatedTitle": "Table duplicated"
+    },
     "apiToken": {
         "create": "create",
         "read": "read",
diff --git a/web-frontend/modules/database/plugin.js b/web-frontend/modules/database/plugin.js
index 12f16f084..e47ec9531 100644
--- a/web-frontend/modules/database/plugin.js
+++ b/web-frontend/modules/database/plugin.js
@@ -1,4 +1,5 @@
 import { DatabaseApplicationType } from '@baserow/modules/database/applicationTypes'
+import { DuplicateTableJobType } from '@baserow/modules/database/jobTypes'
 import {
   GridViewType,
   GalleryViewType,
@@ -225,6 +226,7 @@ export default (context) => {
 
   app.$registry.register('plugin', new DatabasePlugin(context))
   app.$registry.register('application', new DatabaseApplicationType(context))
+  app.$registry.register('job', new DuplicateTableJobType(context))
   app.$registry.register('view', new GridViewType(context))
   app.$registry.register('view', new GalleryViewType(context))
   app.$registry.register('view', new FormViewType(context))