Frontend System Design: File Uploader

deep-diveJune 08, 2026· 7 min read

I got asked to design a file uploader in a frontend interview recently. Not a simple <input type="file"> — a full production uploader with drag-and-drop, progress bars, resumable uploads, and concurrent file handling. Here's how I'd approach it using the RADIO framework.

Requirements Clarification

Before writing any code, nail down the scope. In interviews, they want to see you ask the right questions:

Must-have:

  • Drag-and-drop + click-to-browse
  • File type and size validation
  • Upload progress per file
  • Concurrent uploads (2-3 files at a time)
  • Cancel in-progress uploads
  • Retry failed uploads

Good to clarify:

  • Maximum file size? (Say 100MB per file, 500MB total)
  • Allowed file types? (Images, PDFs, documents)
  • Resume after network drop? (Chunked uploads for large files)
  • Preview thumbnails for images?
  • Upload destination? (Presigned S3 URL is standard)

High-Level Architecture

┌─────────────────────────────────────────┐
│            FileUploader                  │
│  ┌───────────┐  ┌──────────────────┐    │
│  │  DropZone  │  │  FileValidator   │    │
│  └─────┬─────┘  └────────┬─────────┘    │
│        │                  │              │
│  ┌─────▼──────────────────▼──────────┐   │
│  │         UploadQueue                │   │
│  │  (concurrency: 3, priority FIFO)  │   │
│  └─────────────┬─────────────────────┘   │
│                │                         │
│  ┌─────────────▼─────────────────────┐   │
│  │         ChunkedUploader            │   │
│  │  - splits files > 5MB             │   │
│  │  - tracks progress per chunk      │   │
│  │  - retries failed chunks          │   │
│  └─────────────┬─────────────────────┘   │
│                │                         │
│  ┌─────────────▼─────────────────────┐   │
│  │       UploadState (reducer)        │   │
│  │  idle → uploading → paused →      │   │
│  │  done | error                     │   │
│  └───────────────────────────────────┘   │
└─────────────────────────────────────────┘

The key insight: separate the queue from the uploader. The queue manages concurrency and ordering. The uploader handles a single file's lifecycle (chunk, upload, retry). This separation makes each piece testable and replaceable.

Drop Zone with Drag-and-Drop

The drop zone handles two inputs: native drag-and-drop and the hidden file input for click-to-browse.

function createDropZone(container, { onFiles, accept, multiple = true }) {
  const input = document.createElement('input');
  input.type = 'file';
  input.multiple = multiple;
  if (accept) input.accept = accept;
  input.hidden = true;
  container.appendChild(input);

  // Click-to-browse
  container.addEventListener('click', () => input.click());
  input.addEventListener('change', (e) => {
    if (e.target.files.length) onFiles(Array.from(e.target.files));
    input.value = ''; // Reset so same file can be re-selected
  });

  // Drag-and-drop with visual feedback
  let dragCounter = 0;
  container.addEventListener('dragenter', (e) => {
    e.preventDefault();
    dragCounter++;
    container.classList.add('drag-over');
  });

  container.addEventListener('dragleave', (e) => {
    e.preventDefault();
    dragCounter--;
    if (dragCounter === 0) container.classList.remove('drag-over');
  });

  container.addEventListener('drop', (e) => {
    e.preventDefault();
    dragCounter = 0;
    container.classList.remove('drag-over');
    const files = Array.from(e.target.dataTransfer.files);
    if (files.length) onFiles(files);
  });
}

The dragCounter pattern is essential — without it, dragging over child elements fires dragleave and causes flickering. I learned this the hard way when a designer filed a bug about the drop zone "blinking" during drag.

File Validation

Validate immediately on drop. Don't let invalid files enter the queue.

function validateFile(file, { maxSize, accept }) {
  const errors = [];

  if (maxSize && file.size > maxSize) {
    errors.push(`${file.name} exceeds ${formatBytes(maxSize)}`);
  }

  if (accept) {
    const allowedTypes = accept.split(',').map(t => t.trim());
    const ext = '.' + file.name.split('.').pop().toLowerCase();
    const matchesType = allowedTypes.some(pattern => {
      if (pattern.startsWith('.')) return ext === pattern.toLowerCase();
      if (pattern.endsWith('/*')) return file.type.startsWith(pattern.slice(0, -1));
      return file.type === pattern;
    });
    if (!matchesType) {
      errors.push(`${file.name}: type not allowed`);
    }
  }

  return errors;
}

Tip for interviews: mention that you should validate both client-side (instant feedback) and server-side (security). Client validation is UX, not security.

Upload Queue with Concurrency Control

This is where most candidates stumble. You need a queue that processes N files concurrently, not all at once.

class UploadQueue {
  constructor(uploader, { concurrency = 3 } = {}) {
    this.uploader = uploader;
    this.concurrency = concurrency;
    this.pending = [];    // Waiting to start
    this.active = new Map(); // id → AbortController
    this.completed = [];
    this.failed = [];
  }

  add(fileItems) {
    const newItems = fileItems.map(item => ({
      ...item,
      id: crypto.randomUUID(),
      status: 'pending',
      progress: 0,
    }));
    this.pending.push(...newItems);
    this.processQueue();
    return newItems;
  }

  processQueue() {
    while (this.active.size < this.concurrency && this.pending.length > 0) {
      const item = this.pending.shift();
      this.upload(item);
    }
  }

  async upload(item) {
    const controller = new AbortController();
    this.active.set(item.id, controller);
    item.status = 'uploading';

    try {
      await this.uploader.upload(item.file, {
        signal: controller.signal,
        onProgress: (progress) => {
          item.progress = progress;
          this.onItemChange?.(item);
        },
      });
      item.status = 'done';
      item.progress = 100;
      this.completed.push(item);
    } catch (err) {
      if (err.name === 'AbortError') {
        item.status = 'cancelled';
      } else {
        item.status = 'error';
        item.error = err.message;
        this.failed.push(item);
      }
    } finally {
      this.active.delete(item.id);
      this.processQueue(); // Start next in queue
    }

    this.onItemChange?.(item);
  }

  cancel(itemId) {
    const controller = this.active.get(itemId);
    if (controller) {
      controller.abort();
    } else {
      // Remove from pending queue
      this.pending = this.pending.filter(i => i.id !== itemId);
    }
  }

  retry(itemId) {
    const idx = this.failed.findIndex(i => i.id === itemId);
    if (idx === -1) return;
    const [item] = this.failed.splice(idx, 1);
    item.status = 'pending';
    item.progress = 0;
    item.error = undefined;
    this.pending.unshift(item); // Priority to retried items
    this.processQueue();
  }
}

The finally block calling processQueue() is the pump that keeps the queue moving. Each completed upload frees a slot and pulls the next file in.

Chunked Uploads for Large Files

Files over 5MB should be split into chunks. This enables resumable uploads — if the connection drops at 60%, you only re-upload the remaining chunks.

class ChunkedUploader {
  constructor({ chunkSize = 5 * 1024 * 1024, maxRetries = 3 } = {}) {
    this.chunkSize = chunkSize;
    this.maxRetries = maxRetries;
  }

  async upload(file, { signal, onProgress }) {
    if (file.size <= this.chunkSize) {
      // Small file: upload directly
      return this.uploadChunk(file, 0, file.size, { signal, onProgress });
    }

    // Large file: chunk it
    const totalChunks = Math.ceil(file.size / this.chunkSize);
    const uploadedChunks = new Set();

    // Check which chunks already exist on server (resume support)
    const existingChunks = await this.getUploadedChunks(file);
    existingChunks.forEach(i => uploadedChunks.add(i));

    for (let i = 0; i < totalChunks; i++) {
      if (signal?.aborted) throw new DOMException('Upload cancelled', 'AbortError');

      if (uploadedChunks.has(i)) {
        // Already uploaded — skip but update progress
        onProgress?.(Math.round(((i + 1) / totalChunks) * 100));
        continue;
      }

      const start = i * this.chunkSize;
      const end = Math.min(start + this.chunkSize, file.size);
      const chunk = file.slice(start, end);

      let retries = 0;
      while (retries < this.maxRetries) {
        try {
          await this.uploadChunk(chunk, i, totalChunks, {
            signal,
            fileId: await this.getFileId(file),
          });
          uploadedChunks.add(i);
          onProgress?.(Math.round(((i + 1) / totalChunks) * 100));
          break;
        } catch (err) {
          retries++;
          if (retries >= this.maxRetries) throw err;
          await this.delay(1000 * Math.pow(2, retries)); // Exponential backoff
        }
      }
    }

    // Tell server to assemble chunks
    await this.completeUpload(await this.getFileId(file), totalChunks);
  }

  getFileId(file) {
    // Use spark-md5 or similar for consistent file identity
    // For interviews, a combination of name + size + lastModified works
    return `${file.name}-${file.size}-${file.lastModified}`;
  }

  delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  // Stub methods — implementation depends on your backend
  async uploadChunk(chunk, index, total, opts) { /* POST /upload/chunk */ }
  async getUploadedChunks(file) { /* GET /upload/chunks?fileId=... */ }
  async completeUpload(fileId, totalChunks) { /* POST /upload/complete */ }
}

The file.slice() call is important — it creates a lightweight reference to a portion of the file without loading the whole thing into memory. This is how you handle 500MB files without crashing the browser tab.

Progress Tracking with XMLHttpRequest

fetch() doesn't support upload progress natively. For progress, you need XMLHttpRequest or the newer fetch with ReadableStream (less browser support). In interviews, show you know both:

function uploadWithProgress(url, file, { signal, onProgress }) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('PUT', url);

    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        onProgress?.(Math.round((e.loaded / e.total) * 100));
      }
    });

    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(xhr.responseText);
      } else {
        reject(new Error(`Upload failed: ${xhr.status}`));
      }
    });

    xhr.addEventListener('error', () => reject(new Error('Network error')));
    xhr.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')));

    if (signal) {
      signal.addEventListener('abort', () => xhr.abort());
    }

    xhr.send(file);
  });
}

Image Previews

For image files, generate thumbnails client-side using createObjectURL — it's instant and doesn't hit the server:

function createImagePreview(file) {
  if (!file.type.startsWith('image/')) return null;

  const url = URL.createObjectURL(file);

  return {
    url,
    revoke: () => URL.revokeObjectURL(url),
  };
}

In a React component, revoke the URL on unmount to avoid memory leaks:

useEffect(() => {
  return () => {
    previews.forEach(p => p.revoke());
  };
}, []);

Error Handling Strategy

Not all errors are equal. Here's how I categorize them:

Error TypeStrategyUser Message
Network timeoutRetry with backoff"Connection lost. Retrying..."
413 Payload Too LargeNo retry, reduce chunk size"File too large. Try splitting it."
401/403No retry, refresh token"Session expired. Please log in."
429 Rate LimitedRetry after Retry-After header"Uploads paused briefly..."
AbortErrorSilent(UI removes the item)

Accessibility

A file uploader that's inaccessible is broken. Key requirements:

  • The drop zone should be a button (role="button", tabindex="0") with aria-label="Upload files"
  • aria-describedby pointing to instructions ("Drag files here or click to browse")
  • File list uses role="list" with each file as role="listitem"
  • Progress bars use role="progressbar" with aria-valuenow, aria-valuemin="0", aria-valuemax="100"
  • Announce upload completion to screen readers via an aria-live="polite" region
<div role="button"
     tabindex="0"
     aria-label="Upload files"
     aria-describedby="drop-instructions">
  Drop files here or click to browse
</div>
<div id="drop-instructions" class="sr-only">
  Drag files here or press Enter to browse
</div>
<div aria-live="polite" class="sr-only">
  <!-- Dynamically updated: "3 of 5 files uploaded" -->
</div>

Performance Considerations

Memory: Don't hold file contents in memory. Use File references and slice() — the browser streams from disk.

Concurrency: 3 concurrent uploads is the sweet spot. More than 6 saturates the browser's HTTP/2 connection limit per origin. Fewer than 2 wastes bandwidth.

Hashing: For resume support, you need a file hash. Use crypto.subtle.digest('SHA-256') in a Web Worker to avoid blocking the main thread:

// worker.js
self.onmessage = async ({ data: file }) => {
  const buffer = await file.arrayBuffer();
  const hash = await crypto.subtle.digest('SHA-256', buffer);
  const hex = Array.from(new Uint8Array(hash))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
  self.postMessage(hex);
};

For very large files, hash in chunks using the streaming approach — process 1MB at a time instead of loading the whole file.

Putting It Together

Here's the state machine for a single file:

pending → validating → uploading → done
                ↓           ↓
              rejected    error → retry → pending
                          paused → uploading
                          cancelled (terminal)

And the complete usage:

const queue = new UploadQueue(new ChunkedUploader(), { concurrency: 3 });

queue.onItemChange = (item) => {
  renderFileList(queue); // Update UI
};

createDropZone(document.getElementById('dropzone'), {
  accept: 'image/*,.pdf,.doc,.docx',
  onFiles: (files) => {
    const validItems = [];
    for (const file of files) {
      const errors = validateFile(file, { maxSize: 100 * 1024 * 1024, accept: 'image/*,.pdf,.doc,.docx' });
      if (errors.length) {
        showToast(errors.join(', '), 'error');
        continue;
      }
      validItems.push({ file, preview: createImagePreview(file) });
    }
    queue.add(validItems);
  },
});

Key Takeaways

  • Separate queue from uploader — the queue manages concurrency; the uploader handles one file's lifecycle
  • Chunk large files — enables resumable uploads and avoids memory issues with file.slice()
  • Use XMLHttpRequest for progressfetch doesn't support upload progress natively yet
  • Validate client-side for UX, server-side for security — never trust client validation alone
  • Limit concurrency to 2-3 — more than 6 hits browser connection limits per origin
  • Hash in a Web Worker — computing file checksums on the main thread will freeze the UI
  • Revoke object URLs — memory leaks from createObjectURL are real and easy to miss

This is the kind of system design that shows you think about production concerns — not just "it works" but "it works when the network drops, when the user uploads 50 files, and when they're on a phone with 2 bars."