How would you implement a file upload with progress in React?

Advanced20 min interview
Skills tested:
Upload Progress TrackingConcurrent Async State ManagementAbortController UsageResumable Upload ArchitecturePerformance OptimizationBrowser API Knowledge

Advertisement

🧩 Scenario

You need a reusable file upload component that: - Shows upload progress (percentage + bytes) - Allows cancelling an in-flight upload - Supports retrying on failure - Handles multiple file uploads with per-file progress - Provides UX for large files (show estimated time, chunking notes) Implement a demo component and explain tradeoffs.

🧠 Architecture Walkthrough

Why XHR and Not Fetch for Progress

fetch does not expose upload progress in any browser-native way. The Streams API theoretically enables wrapping a ReadableStream for request body monitoring, but the spec for upload progress via fetch is not finalized and browser support is inconsistent as of 2025.

XMLHttpRequest, despite being the older API, has a dedicated upload.onprogress event that fires with ProgressEvent objects containing loaded and total bytes. This makes XHR the pragmatic choice for any upload component that needs progress feedback.

The demo simulates this using a custom simulateUpload function with AbortController integration, because running real HTTP requests in a sandboxed demo environment is not possible but the patterns (abort signal checking, progress callback, error discrimination) mirror what a real XHR implementation would look like.

Per-File State and the AbortController Map

The abortControllers ref holds a Map keyed by upload item ID. This is the right data structure because multiple uploads run concurrently and each needs an independently cancellable handle. A single ref (instead of state) is used because the map is an operational side-effect structure its mutations should not trigger re-renders.

When startUpload is called for an item, it creates a new AbortController, stores it in the map under the item's ID, passes controller.signal to simulateUpload, and removes the entry from the map in the finally block.

This cleanup is important: if the entry were not removed, the map would grow indefinitely as uploads complete, and subsequent cancelUpload or pauseUpload calls might try to abort a controller that no longer corresponds to an active request.

Status as the Single Source of Truth for UI

The UploadStatus enum (IDLE, UPLOADING, PAUSED, COMPLETED, ERROR, CANCELLED) drives all conditional rendering in FileUploadItem. This is a deliberate design: instead of having multiple boolean flags (isUploading, isCancelled, hasError) that can conflict with each other, a single status value represents the complete state of a file at any moment.

The button group at the bottom of each item renders entirely differently based on this status IDLE shows "Start Upload", UPLOADING shows "Pause" and "Cancel", ERROR and CANCELLED show "Retry".

This eliminates impossible states: a file cannot be simultaneously UPLOADING and CANCELLED. When error discrimination happens in the catch block (error.message.includes('cancelled')), a single status update setUploadItems(...) atomically transitions the item to either CANCELLED or ERROR, keeping the UI in sync without additional boolean state.

Retry and the State Reset Order

The retryUpload function first resets the item's state to IDLE with zeroed progress and then immediately calls startUpload. The order matters: if startUpload were called before the state reset, the UI would briefly show the old error state while the new upload is already running, and the "Uploading" status update inside startUpload would race with the reset.

By resetting to IDLE first in a single setUploadItems call, React batches the transition cleanly. The reset also clears the error field (error: undefined), which prevents an old error message from appearing in the UI if the component re-renders during the retry.

This is a detail that matters for UX: a user who sees the progress bar at 73% with an error message alongside it has a confusing experience.

💡 Key Code Explained

const startUpload = async (itemId) => {
  const abortController = new AbortController();
  abortControllers.current.set(itemId, abortController);

  setUploadItems((prev) =>
    prev.map((item) =>
      item.id === itemId ? { ...item, status: UploadStatus.UPLOADING } : item,
    ),
  );

  try {
    const result = await simulateUpload(
      item.file,
      (progress) => {
        setUploadItems((prev) =>
          prev.map((item) =>
            item.id === itemId ? { ...item, progress } : item,
          ),
        );
      },
      abortController.signal,
    );
    // ... success handling
  } catch (error) {
    const isCancelled = error.message.includes('cancelled');
    const newStatus = isCancelled ? UploadStatus.CANCELLED : UploadStatus.ERROR;
    // ... error handling
  } finally {
    abortControllers.current.delete(itemId);
  }
};

The setUploadItems call inside the progress callback fires on every progress tick. This means React re-renders the entire list on each tick for every active upload. For a demo with a few files this is fine, but in production with 20 concurrent uploads each firing progress events every 100ms, you would accumulate 200 re-renders per second.

The production solution is to either throttle the progress updates (update state at most every 250ms per file), use useReducer to batch updates, or move to a ref-based progress tracker that directly mutates the DOM progress bar style without going through React state.

The finally block cleanup abortControllers.current.delete(itemId) is critical without it, completed uploads leave stale AbortController entries in the map that will never be used but can cause confusion if IDs are ever reused.

const pauseUpload = (itemId) => {
  const controller = abortControllers.current.get(itemId);
  if (controller) {
    controller.abort();
    setUploadItems((prev) =>
      prev.map((item) =>
        item.id === itemId ? { ...item, status: UploadStatus.PAUSED } : item,
      ),
    );
  }
};

const resumeUpload = (itemId) => {
  startUpload(itemId);
};

The "pause" here is actually a cancel plus a status rename the upload is fully aborted and the status is set to PAUSED instead of CANCELLED. When the user resumes, startUpload restarts the upload from byte 0.

This is "fake pause" genuine pause-and-resume requires chunked upload with server-side checkpoint tracking, where the client can restart from the last successfully uploaded chunk rather than from the beginning.

The demo's approach is honest enough for a UI prototype and the interview context, but you should be prepared to explain the distinction: true resumable uploads require the Tus protocol or S3 multipart upload, where the server issues a unique upload ID and accepts byte range re-submissions.

const simulateUpload = (file, onProgress, signal) => {
  return new Promise((resolve, reject) => {
    let loaded = 0;
    const total = file.size;

    const upload = () => {
      if (signal?.aborted) {
        reject(new Error('Upload cancelled'));
        return;
      }
      // ... progress calculation and scheduling
    };

    setTimeout(upload, 100);
  });
};

The signal?.aborted check at the top of each iteration is the mechanism that makes cancellation work. The AbortController.abort() call does not interrupt an already-running setTimeout callback it only sets signal.aborted to true and dispatches an abort event.

So the check must be done at the start of each upload iteration, not just once at the start of the function. This polling approach mirrors how XHR abort works: xhr.abort() does not immediately reject the promise, it fires an abort event and the promise resolution logic needs to check for it.

The pattern of checking signal.aborted on each iteration is the same pattern you would use with a streaming upload that processes chunks in a loop.

⚖️ Tradeoffs

ApproachProCon
XHR with upload.onprogress (production equivalent)Native progress events, cancel via abort(), widely supportedOlder API, verbose, no Promise native support must promisify
fetch + ReadableStream (experimental)Modern API, cleaner codeUpload progress via streams not standardized; Safari support inconsistent
Chunked upload with Tus protocolTrue pause/resume, server can reassemble, reliable for large filesRequires server-side Tus implementation; more client complexity
S3 multipart upload (presigned URLs)Direct-to-storage, no server bandwidth cost, parallel chunk uploadRequires presigned URL generation server-side; complex retry logic per part
Single-file upload (no progress)Simplest implementationZero feedback for large files; users have no signal the upload is working

🎯 What Interviewers Actually Check

  • Whether you know that fetch does not support upload progress natively and can name what primitive would be needed (upload ReadableStream or XHR's upload.onprogress)
  • Whether you store AbortControllers in a ref (not state) and understand why mutating a ref during upload does not need to trigger re-renders
  • Whether your retry implementation resets state before restarting the upload, not after the order of state updates matters for what the user sees during the transition
  • Whether you distinguish between "fake pause" (abort and restart) and "true pause" (chunked upload with server-side resume) conflating them in an interview is a red flag
  • Whether you mention client-side validation (file type, file size) as a guard before the upload begins the demo accepts any file, but production code needs a validation gate

❓ Follow-Up Questions

  1. The progress callback fires on every simulated chunk, which triggers a React re-render on each tick. For 10 concurrent uploads each updating every 100ms, that is 100 re-renders per second. How would you reduce this while keeping the progress bar visually smooth?
  2. The "pause" feature in the demo actually cancels and restarts the upload from zero. Describe the server-side protocol changes needed to implement true resume what would the client need to send, and what would the server need to store?
  3. How would you add client-side file type and size validation that runs before the upload starts and shows per-file error messages without blocking valid files in the same batch?
  4. If the user closes the browser tab mid-upload, what happens to the in-flight request? Is there any browser API that would let you warn the user or attempt to complete the upload before the tab closes?
  5. Your backend engineer says large file uploads are hammering the server's memory because it buffers the entire file before writing to storage. What upload architecture change would fix this without requiring frontend changes?

🎮 Live Demo

📝 Summary

A file upload component with progress is deceptively simple on the surface but contains several design decisions that reveal whether a developer understands browser APIs deeply.

The choice of XHR over fetch is not a legacy preference it reflects a real gap in the fetch spec that has not been closed despite years of discussion. The per-file AbortController map stored in a ref is a pattern that appears whenever you need to cancel concurrent async operations without triggering re-renders.

The single-status-enum approach to file state eliminates entire categories of impossible UI states that arise when multiple boolean flags can contradict each other. The "fake pause" distinction is the kind of tradeoff that separates a thoughtful answer from a superficial one: acknowledging that true resumability requires server-side cooperation with chunked upload protocols, not just stopping and restarting on the client.

These are exactly the details that senior frontend interviews probe for, because they are where engineering judgment shows.

Frequently Asked Questions

Why use XMLHttpRequest instead of fetch for progress?

fetch doesn't expose upload progress natively — XMLHttpRequest provides upload.onprogress for precise progress events.

When should I use chunked uploads?

For large files (>50MB) or unstable networks; chunking improves resumability and reduces re-upload cost.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement