Skip to content
HexaTransfer
Back to blog
Technical Deep Dives

Progressive File Upload: UX and Technical Guide

Build progressive file upload experiences with drag-and-drop, progress bars, and graceful error handling for better user experience.

A progressive file upload gives users immediate, trustworthy feedback at every stage: the instant they drop a file, a selection confirmation; during upload, a smooth progress bar with realistic ETA; on failure, specific retry options; after completion, a clear success state with next actions. The technical ingredients are drag-and-drop via the HTML5 DataTransfer API, chunked uploads with resumability, fetch streaming with ReadableStream for byte-accurate progress, and state management that survives tab reloads via IndexedDB. Done right, a user uploading a 5 GB file never wonders if the app is frozen.

What "Progressive" Actually Means Here

Progressive has two meanings in this context. One: progressive enhancement, so the upload works as a plain <input type="file"> POST on a browser from 2012, and gains drag-and-drop, chunking, and retries when JavaScript is available. Two: progressive disclosure, where the UI reveals complexity only when needed — show a percentage during upload, but show retry details only on failure. Both meanings point at the same principle: the user should never hit a dead end, and never wait without information.

The failure mode to avoid is the "spinner of doom" — a generic loading animation that gives no indication of progress, ETA, or whether something went wrong. Users cancel uploads they don't trust.

Drag-and-Drop That Doesn't Fight the Browser

The HTML5 drag-and-drop API is infamous for bugs. A few rules that make it tolerable:

const dropzone = document.querySelector('.dropzone');
dropzone.addEventListener('dragover', (e) => {
  e.preventDefault();
  dropzone.classList.add('dragging');
});
dropzone.addEventListener('dragleave', () => {
  dropzone.classList.remove('dragging');
});
dropzone.addEventListener('drop', (e) => {
  e.preventDefault();
  dropzone.classList.remove('dragging');
  handleFiles([...e.dataTransfer.files]);
});

Call preventDefault on dragover or the drop target won't accept the drop. Use e.dataTransfer.items instead of files if you need to accept folders via webkitGetAsEntry() — it's the only way to recursively capture directory contents on Chrome and Firefox.

Make the fallback usable too: a visible <label> wrapping a styled <input type="file" multiple> works for 100% of users including keyboard and screen reader navigation.

Showing Progress Users Believe

Progress bars jump around for three reasons: uneven chunk sizes, TCP slow-start, and buffering in the network stack. Smooth them with a 2-second trailing average:

const samples = []; // [{ time, bytes }]
function recordSample(bytes) {
  const now = performance.now();
  samples.push({ time: now, bytes });
  while (samples.length > 1 && now - samples[0].time > 2000) samples.shift();
}
function throughput() {
  if (samples.length < 2) return 0;
  const delta = samples[samples.length - 1];
  const base = samples[0];
  return (delta.bytes - base.bytes) / ((delta.time - base.time) / 1000);
}

Compute ETA as (totalBytes - uploadedBytes) / throughput(), clamp the display to at least 5 seconds, and format it in human terms: "about 2 minutes" not "124.3 seconds." Show both a percentage and a byte counter ("340 MB of 2.1 GB") — users cross-check the two when something feels wrong.

Error States With Actionable Recovery

Generic "upload failed" messages destroy trust. Classify failures into five buckets and surface each distinctly:

  • Network drop (offline event, TCP reset): "Reconnecting..." with automatic retry
  • Server 5xx: "Server error, retrying in 5s" with manual retry button
  • Server 4xx (413 too large, 415 bad type): "File rejected: too large" with file replacement
  • Auth expired (401, 403): "Session expired, sign in to continue"
  • Client crash (JS error, browser killed tab): Recovery from IndexedDB on reload

Pair the message with the one action that resolves it. If the user is offline, show the online/offline state monitored via navigator.onLine and the online event.

Tracking Bytes With Fetch Streams

XMLHttpRequest.upload.onprogress has been the traditional way to track upload progress, but it's flaky on HTTP/3 and misses bytes queued in the send buffer. The modern approach uses ReadableStream to count bytes as they're produced:

function trackedStream(blob, onBytes) {
  let sent = 0;
  return new ReadableStream({
    async pull(controller) {
      const reader = blob.stream().getReader();
      while (true) {
        const { done, value } = await reader.read();
        if (done) { controller.close(); return; }
        sent += value.byteLength;
        onBytes(sent);
        controller.enqueue(value);
      }
    }
  });
}

Pass the stream as the body to fetch with duplex: 'half'. Safari support for request streams landed in 17.4; before that, fall back to XMLHttpRequest. This gives you millisecond-accurate progress tied to actual bytes handed to the network stack.

Pause, Resume, and Cancel

Users expect a pause button on anything that takes more than a minute. With chunked uploads, pause is just "stop dispatching new chunks," and resume picks up where the work queue left off. Cancel uses AbortController:

const ctrl = new AbortController();
cancelButton.onclick = () => ctrl.abort();
await fetch(url, { method: 'PUT', body: blob, signal: ctrl.signal });

On abort, clean up: DELETE the upload session on the server so storage doesn't leak, clear the IndexedDB entry, return to the initial state. Pause should preserve state; cancel should destroy it. Make the distinction visible in the UI.

Surviving Tab Reloads

Persist upload state after each successful chunk:

await idb.put('uploads', {
  sessionId, fileFingerprint, fileName, fileSize,
  completedChunks: [...done], updatedAt: Date.now()
}, sessionId);

The fingerprint is a SHA-256 of the file's first 1 MB plus size and lastModified — enough to re-identify the file when the user picks it again after reload. On page load, check IndexedDB for sessions under an hour old and offer resumption: "You have an upload in progress from 12 minutes ago. Resume?" Don't auto-resume without consent — users sometimes reload specifically to cancel.

Accessible and Keyboard-Friendly Interactions

A dropzone that only responds to mouse drags fails screen reader users and keyboard users. Add:

  • role="button" and tabindex="0" on the dropzone
  • Enter/Space key handler that clicks the file input
  • aria-live="polite" on the progress region so screen readers announce milestones
  • Visible focus styles, not just hover
  • Clear labels — "Upload file" beats "Browse" which beats a bare icon

Keyboard testing is fast: unplug your mouse for 10 minutes and try to complete an upload. If you can't, neither can a portion of your users.

HexaTransfer's uploader uses exactly this progressive pattern — plain-form fallback, drag-and-drop enhancement, fetch streaming, IndexedDB-backed resume, and specific error recovery. Try it at hexatransfer.com — free, no account, 10 GB max.

The Details Users Actually Notice

The polish that separates forgettable uploaders from great ones lives in small moments: a drop animation that confirms the file was captured, a progress bar that fills smoothly instead of jumping, an ETA that gets more accurate over time instead of flipping wildly, specific error messages that tell you what to do next, a resume prompt after an accidental refresh, a completion state that persists long enough to copy the share link, and cancel behavior that actually stops the upload immediately. Each of these is a few lines of code. Ship them all and your uploader feels an order of magnitude better than the default <input type="file"> treatment.

Send large files securely with end-to-end encryption

Transfer files up to 10 GB for free with end-to-end encryption. No account required. Your files are encrypted in your browser before upload — no one else can read them.

Send a file