Skip to content
fearchitect
UI System-Design Patterns

File Upload

Presigned URLs, chunked uploads, progress UI, and retry.

By Abas TurabliReviewed

Summary

File uploads go directly from browser to object storage via presigned URLs — your server never proxies the bytes. Large files need chunked, resumable uploads so a network drop does not restart from zero. Client-side validation (type, size) gives immediate feedback; XHR progress events drive the progress bar. Drag-and-drop uses the DataTransfer API.

Jump to the interview angle

Presigned URL upload

A presigned URL is a time-limited, pre-authenticated URL issued by your backend that lets the browser PUT a file directly to object storage (S3, GCS, R2). Your server signs the URL with credentials it already holds, returns the URL to the client, and the client streams the file straight to storage. Your origin never sees the payload — bandwidth, CPU, and memory are offloaded entirely to the storage service.

Presigned upload flow

  1. 1

    Client requests a presigned URL

    Send a lightweight POST to your API with file name, size, and MIME type. The server validates the request — size caps, allowed types, auth — then calls the storage SDK to generate a signed PUT URL, typically valid for 15 minutes.

  2. 2

    Browser PUTs directly to storage

    The client PUTs the file binary to the presigned URL. The storage service validates the signature and writes the object. CORS on the bucket must allow PUT from your origin. Use XHR (not fetch) here if you need upload progress events.

  3. 3

    Client notifies the backend

    After the PUT succeeds (HTTP 200/204), the client calls a second API endpoint with the storage key. The backend records the metadata, triggers any post-processing (virus scan, transcoding), and marks the upload complete.

Upload with progress via XHR

fetch lacks upload progress. XHR exposes xhr.upload.onprogress, which fires as bytes leave the browser. Wire it to a state variable; the presigned URL is already in hand before this runs.

Upload with progress via XHRts
async function uploadFile(
  file: File,
  presignedUrl: string,
  onProgress: (pct: number) => void,
): Promise<void> {
  // Client-side validation before the network round-trip.
  const ALLOWED = ["image/jpeg", "image/png", "application/pdf"];
  const MAX_BYTES = 100 * 1024 * 1024; // 100 MB
  if (!ALLOWED.includes(file.type)) throw new Error("Unsupported file type");
  if (file.size > MAX_BYTES) throw new Error("File exceeds 100 MB limit");

  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();

    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
    };

    xhr.onload = () => {
      if (xhr.status === 200 || xhr.status === 204) resolve();
      else reject(new Error(`Storage PUT failed: ${xhr.status}`));
    };

    xhr.onerror = () => reject(new Error("Network error during upload"));

    xhr.open("PUT", presignedUrl);
    xhr.setRequestHeader("Content-Type", file.type);
    xhr.send(file); // streams the File blob; no base64, no FormData
  });
}

xhr.upload.onprogress fires per chunk as bytes leave the OS buffer. fetch's ReadableStream alternative requires a TransformStream to count bytes and does not work in all environments.

Your server signs the URL and records metadata — it never proxies the file bytes.

Chunked and resumable uploads for large files

For files over ~100 MB, use S3 Multipart Upload or the TUS protocol. Both split the file into parts (5 MB minimum for S3), upload each part independently, and let a stalled upload resume from the last completed part. The browser tracks which parts are done in localStorage or a server-side session. TUS has an open spec with client libraries for browsers.

Drag-and-drop and concurrency

  • Attach dragover (call preventDefault) and drop listeners; read files from event.dataTransfer.files.
  • Cap concurrent uploads — 3 at once is a safe default; more saturates the connection and blocks progress feedback.
  • Retry transient failures (5xx, network error) with exponential backoff; never retry 4xx responses.
  • Revoke object URLs created with URL.createObjectURL after preview to free memory.

Interview angle

Interviewers want the presigned-URL flow, why you do not proxy through your server, how chunked uploads survive network drops, and how XHR exposes upload progress that fetch does not.

Soundbite: "Generate a presigned URL server-side, PUT the file straight to S3, then notify the backend — your server never touches the bytes."

Key terms

Presigned URL
Time-limited signed URL that authorises one specific HTTP operation (PUT/GET) on an object storage resource without exposing credentials.
S3 Multipart Upload
S3 API that splits a file into parts (min 5 MB each), uploads them in parallel, and assembles the object server-side.
TUS protocol
Open resumable upload protocol; tracks byte offset server-side so interrupted uploads resume from where they stopped.
DataTransfer.files
FileList exposed by the browser's drop event containing files dragged onto the drop target.
xhr.upload.onprogress
XHR event that fires as upload bytes leave the browser, enabling per-file progress tracking.

Further reading

Search fearchitect

Jump to a topic, mode, or action.