File Uploads in Vue: Progress, Preview and Error Paths
AI generated
<v/>
{ }
Vue 3 · File Upload · Axios · FileReader · UX
File Uploads in Vue: Progress, Preview and Error Paths
from file selection to a robust retry mechanism

A file upload without a progress indicator, without a preview and without understandable error messages frustrates users. Vue 3 provides all the building blocks for a professional file upload with the Composition API, the FileReader API and Axios upload progress, once you know how they fit together.

14 min read Upload progress · File preview · Drag and drop · Validation · Retry Vue 3 · Axios · FileReader API · TypeScript

1. File Upload in Vue: Fundamentals and Pitfalls

A file upload in Vue seems simple at first: an <input type="file">, an event handler and an Axios POST. The difficulty is not in the happy path but in the edge scenarios: What happens when the connection drops during upload? What does the user see when a file exceeds the size limit? How does the user interface behave with several simultaneous uploads? Answering these questions before they occur in production is the core of a well thought out file upload in Vue concept.

The most common mistake when implementing file uploads in Vue is missing state separation: upload progress, preview, validation errors and server response are all managed in a single, cluttered component. This leads to hard to test code and prevents reusing the upload logic elsewhere in the application. The solution is a dedicated useFileUpload composable that encapsulates the entire upload lifecycle and exposes only a clean, reactive API to the component.

2. Implementing File Input and Drag and Drop Cleanly

The native <input type="file"> is the starting point of every file upload in Vue. It can be made fully invisible with CSS and replaced by an arbitrarily styled button or a drag-and-drop zone, while the input element still handles the actual file selection. Using a template ref on the input element, you can programmatically call inputEl.value.click(), so any HTML element reacts to clicks by opening the file selection dialog, without the input element ever having to be visible.

For drag and drop you register the events dragenter, dragleave, dragover and drop on the drop zone. The dragover event must be handled with event.preventDefault(), otherwise the browser prevents the file from being dropped. Distinguishing between dragenter and dragleave with nested child elements is a classic pitfall: when the pointer moves from the parent drop area into a child element, dragleave fires on the parent element even though the pointer is still within the zone. The robust solution is a counter approach instead of a boolean flag for the drag state - in the composable, dragenter increments the counter, dragleave decrements it, and isDragging is true exactly when the counter is greater than zero.


<!-- FileDropZone.vue - accessible drag-and-drop upload area -->
<template>
  <div
    class="border-2 border-dashed rounded-xl p-8 text-center transition-colors"
    :class="isDragging ? 'border-green-500 bg-green-50' : 'border-slate-300 bg-slate-50'"
    @dragenter.prevent="dragCount++"
    @dragleave.prevent="dragCount--"
    @dragover.prevent
    @drop.prevent="onDrop"
    @click="fileInput?.click()"
    role="button"
    :aria-label="label"
  >
    <input
      ref="fileInput"
      type="file"
      class="sr-only"
      :accept="accept"
      :multiple="multiple"
      @change="onFileChange"
    />
    <slot :isDragging="isDragging">
      <p class="text-slate-500 text-sm">
        Drag files here or <span class="text-green-700 font-semibold">click to select</span>
      </p>
    </slot>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'

const props = defineProps<{
  accept?: string
  multiple?: boolean
  label?: string
}>()

const emit = defineEmits<{
  filesSelected: [files: File[]]
}>()

// Counter-based drag detection - avoids false dragleave on child elements
const dragCount = ref(0)
const isDragging = computed(() => dragCount.value > 0)
const fileInput = ref<HTMLInputElement | null>(null)

function onDrop(event: DragEvent) {
  dragCount.value = 0
  const files = Array.from(event.dataTransfer?.files ?? [])
  if (files.length) emit('filesSelected', files)
}

function onFileChange(event: Event) {
  const input = event.target as HTMLInputElement
  const files = Array.from(input.files ?? [])
  if (files.length) emit('filesSelected', files)
  // Reset input so the same file can be re-selected
  input.value = ''
}
</script>

3. Client-side Validation Before the Upload

Validation is the first filter in every file upload in Vue: it prevents unnecessary network requests and gives the user immediate feedback. The most important checks are file type, file size and file count for multi-file uploads. The file type should be checked not only against the file extension but also against the MIME type reported by the operating system for the file. A file named virus.jpg.exe has the MIME type application/x-msdownload; a pure extension check would let it through.

You read the file size via file.size in bytes. For a meaningful error message you convert it into readable units: bytes, kilobytes, megabytes. The validation function should return a typed result object that contains all failed checks with a reason, not just true or false. This lets the user interface show specific messages like "File exceeds the 10 MB limit" or "Only JPEG and PNG are allowed", instead of a generic error message that leaves the user in the dark.

4. File Preview with the FileReader API

The FileReader API makes it possible to load a local file as a data URL into the browser before it is uploaded to the server. For images this produces an instant preview that confirms to the user that the right file was selected. Integrating it into a file upload in Vue is a classic use case for a promise wrapper function: wrapping the callback-based FileReader API in a promise function makes it directly usable with await and considerably simplifies error handling.

An important aspect of the preview implementation is memory management: data URLs are strings that contain the entire file content base64 encoded. For large images this can occupy several megabytes of memory. For large files, URL.createObjectURL(file) is the better alternative: it creates a temporary URL pointing to the file's memory area without duplicating the content. These object URLs must be released manually with URL.revokeObjectURL(url), ideally in a Vue onUnmounted hook or when the preview is no longer needed.


// composables/useFilePreview.ts
// Generates file previews using Object URLs for memory efficiency
import { ref, onUnmounted } from 'vue'

export interface FilePreview {
  file: File
  previewUrl: string | null
  isImage: boolean
  formattedSize: string
}

export function useFilePreview() {
  const previews = ref<FilePreview[]>([])
  // Track created object URLs for cleanup
  const objectUrls: string[] = []

  function createPreview(file: File): FilePreview {
    const isImage = file.type.startsWith('image/')
    let previewUrl: string | null = null

    if (isImage) {
      // createObjectURL is memory-efficient - no base64 duplication
      previewUrl = URL.createObjectURL(file)
      objectUrls.push(previewUrl)
    }

    return {
      file,
      previewUrl,
      isImage,
      formattedSize: formatBytes(file.size),
    }
  }

  function addFiles(files: File[]) {
    previews.value.push(...files.map(createPreview))
  }

  function removeFile(index: number) {
    const preview = previews.value[index]
    if (preview.previewUrl) {
      URL.revokeObjectURL(preview.previewUrl)
    }
    previews.value.splice(index, 1)
  }

  // Release all object URLs when component unmounts
  onUnmounted(() => {
    objectUrls.forEach(url => URL.revokeObjectURL(url))
  })

  return { previews, addFiles, removeFile }
}

function formatBytes(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}

5. Upload Progress with Axios onUploadProgress

Axios supports the XMLHttpRequest onprogress event directly through the onUploadProgress configuration option. The callback function receives a ProgressEvent with the fields loaded (bytes transferred) and total (total size). The progress percentage is derived from Math.round((loaded / total) * 100). In the composable this value is held as a reactive ref and used in the template as the width of a progress bar or as a text indicator. This makes the file upload in Vue feel alive to the user: they see that something is happening and can estimate when the upload will finish.

When uploading several files at once, a progress indicator per file plus an overall progress is recommended. The overall progress is calculated from the sum of transferred bytes across all active uploads divided by the sum of their total sizes, not as an average of the percentage values, since that produces incorrect results for files of different sizes. A large file that is 10 percent uploaded and a small file that is 90 percent uploaded together yield a true overall progress of well under 50 percent, even though the average of the percentage values would be 50 percent.

6. The useFileUpload Composable as a Reusable Unit

The useFileUpload composable encapsulates the entire file upload in Vue lifecycle: file selection, validation, preview generation, the actual upload with progress, error handling and cleanup after the upload. The component that uses it only has to take care of the template: it displays whatever reactive values the composable provides and calls methods when the user interacts. The result is a component that is fully testable, because the entire upload logic lives in a function that can be tested independently of Vue.

The composable's interface exposes: files (array of selected files with preview and progress), isUploading (boolean), totalProgress (0-100), errors (array of error objects), addFiles(), removeFile(), upload() and cancelAll(). This interface is complete and self-contained: nothing outside the composable needs to know implementation details of the upload. Other places in the application that need file uploads in Vue call the same function and get an identical interface.

7. Error Paths: Network Errors, Server Errors and Timeouts

Error paths in a file upload in Vue fall into three categories: network errors (no connection, connection dropped), server errors (4xx, 5xx HTTP status codes) and client-side timeouts. Axios throws errors for all three cases, which you recognize with axios.isAxiosError(error) and then distinguish based on error.response (server error), error.request (no server reached) and error.code === 'ECONNABORTED' (timeout). Good error handling translates these technical details into understandable messages that tell the user what they can do.

A particularly common error path in production applications is the timeout for large files: the server receives the file, processes it (e.g. image conversion), and the connection gets cut by a proxy timeout before the response comes back. The solution is not to extend the timeout, but to decouple the upload from the processing status: the server responds immediately with a job ID, and the client polls the status asynchronously. For simpler use cases, a calibrated timeout of 60 to 120 seconds plus a clear message on expiry is enough, instead of leaving the user waiting endlessly without feedback.


// api/uploadApi.ts - typed upload function with progress and cancellation
import axios, { type AxiosProgressEvent, type CancelTokenSource } from 'axios'

export interface UploadOptions {
  onProgress?: (percent: number) => void
  cancelToken?: CancelTokenSource
}

export interface UploadResult {
  fileId: string
  url: string
  filename: string
}

export async function uploadFile(
  file: File,
  endpoint: string,
  options: UploadOptions = {}
): Promise<UploadResult> {
  const formData = new FormData()
  formData.append('file', file)

  try {
    const response = await axios.post<UploadResult>(endpoint, formData, {
      headers: { 'Content-Type': 'multipart/form-data' },
      timeout: 120_000, // 2-minute timeout for large files
      cancelToken: options.cancelToken?.token,
      onUploadProgress(event: AxiosProgressEvent) {
        if (event.total) {
          const percent = Math.round((event.loaded / event.total) * 100)
          options.onProgress?.(percent)
        }
      },
    })
    return response.data
  } catch (error) {
    if (axios.isCancel(error)) {
      throw new Error('Upload cancelled')
    }
    if (axios.isAxiosError(error)) {
      if (!error.response) throw new Error('No connection to the server. Please check your internet connection.')
      if (error.code === 'ECONNABORTED') throw new Error('Upload timeout. Please try again with a smaller file.')
      if (error.response.status === 413) throw new Error('File too large. Maximum: 50 MB.')
      if (error.response.status === 415) throw new Error('File format not supported.')
      throw new Error(`Server error (${error.response.status}). Please try again later.`)
    }
    throw error
  }
}

8. Retry Logic and Cancelling Running Uploads

Network errors in file uploads in Vue are temporary: a brief connection drop, an overloaded server, a proxy restart. Built-in retry logic considerably improves the user experience, because the user does not have to manually reload. The basic pattern: a maximum of three attempts with exponential backoff, waiting 1 second after the first failure, 2 seconds after the second, 4 seconds after the third. Server errors with 4xx status codes are not retried, because they indicate an application error that will not be fixed by sending the request again. Only 5xx server errors and network errors justify a retry attempt.

Cancelling a running upload in Axios works via cancel tokens. A CancelTokenSource object is created when the upload starts and the cancel() method is called when the user aborts. In the composable you store a cancel token for each running upload, so individual uploads from a multi-file upload queue can be cancelled without affecting the others. After cancellation the state of the affected file is set to cancelled and the preview URL is released. This is a complete error path of the file upload in Vue that is often simply ignored without this handling.

9. Upload Strategies Compared

Depending on requirements, there are various technical approaches for file uploads in Vue. The choice depends on file size, network reliability and server architecture.

Strategy Suitable for Advantages Disadvantages
Simple POST Files < 10 MB Simple, no server logic No progress, no resume
Axios with progress Files < 100 MB Progress, cancel, retry No resume on abort
Chunked upload Files > 100 MB Resumable, stable Server-side chunk logic needed
Presigned URL (S3) Large files, cloud storage No server bandwidth overhead CORS configuration, URL expiry
tus protocol Reliability critical Resume, open protocol Dependency on tus server

For most web applications, Axios with progress is the pragmatic default. Chunked uploads pay off from file sizes above 100 MB or when users are expected to have a poor network connection. Presigned URLs are the preferred solution when files should land directly in cloud storage (AWS S3, Google Cloud Storage) without burdening the application server as a pass-through station.

Mironsoft

Vue 3 frontend development, upload infrastructure and UX engineering

A file upload feature without robust error handling?

We implement complete file upload solutions in Vue 3, with progress, preview, retry and clean error handling for every error path that occurs in production.

Upload composable

Complete upload logic as a reusable composable with TypeScript

Error paths

All network, server and timeout scenarios fully covered

UX review

Optimize upload UX based on real user feedback patterns

10. Summary

A professional file upload in Vue consists of several layers: drag and drop with counter-based drag detection, client-side validation before the upload, preview generation with object URLs instead of data URLs, upload progress via Axios onUploadProgress, and complete error handling for network errors, server errors and timeouts. The useFileUpload composable encapsulates this logic and gives the component a clean, reactive API without internal implementation details.

The biggest lever for user experience is clear error handling: users who know why an upload failed and what they can do about it do not simply give up. Retry logic with exponential backoff for temporary network errors, the cancel token for deliberate aborts, and a clear distinction between client validation errors and server errors turn the file upload in Vue into a reliable feature rather than a source of errors.

File Upload in Vue - The Essentials at a Glance

Drag and drop

Counter approach for isDragging instead of a boolean, prevents flickering with nested child elements. Input reset after selection allows re-selecting the same file.

Preview and memory

URL.createObjectURL() instead of a data URL for images. Release object URLs in onUnmounted with URL.revokeObjectURL(), no memory leak.

Upload progress

Axios onUploadProgress delivers loaded and total. Calculate overall progress via byte sums, not as an average of percentage values.

Error paths

Handle network errors, 4xx, 5xx and timeout separately. Retry only for 5xx and network errors. Cancel token for user-initiated aborts.

11. FAQ: File Uploads in Vue with Progress, Preview and Error Paths

1Show upload progress with Axios?
The onUploadProgress option in Axios: event.loaded / event.total * 100. Store as a reactive ref and visualize as a bar in the template.
2createObjectURL vs. FileReader?
createObjectURL: memory efficient, synchronous. FileReader: reads file content as base64 into RAM. For image previews always use createObjectURL.
3isDragging flickers with child elements?
Counter instead of boolean: dragenter increments, dragleave decrements. isDragging = counter > 0. No flickering with nested elements.
4Cancel an Axios upload?
Create a CancelTokenSource, pass it as cancelToken. source.cancel() aborts it. Check with axios.isCancel() in the catch block.
5Client-side validation?
MIME type via file.type, not just the file extension. file.size for size. Report all errors collected, not one after another.
6When chunked upload?
From 100 MB or an unstable connection. Chunked allows resuming. For most apps, Axios plus retry is enough.
7Prevent memory leaks with previews?
Track object URLs in an array and release all of them in onUnmounted. Call revokeObjectURL immediately when removing a file.
8Implement retry logic?
Exponential backoff: 1s, 2s, 4s. Max 3 attempts. Retry only 5xx and network errors. Not 4xx.
9Composable or Pinia action for upload?
Composable. Upload state is local to the component. Global store only if other parts need to react to upload status.
10Upload timeouts for large files?
Axios timeout of 60 to 120 seconds. Show an understandable message on ECONNABORTED. For large files: the server responds with a job ID, the client polls the status.