Frontend / Frontend system design / 07_file_uploader_with_resume.md

Design: File Uploader with Resume

Updated 7 min read source
On this page17
  1. TL;DR
  2. Requirements to clarify
  3. Architecture
  4. API contract
  5. Client state per file
  6. Chunking strategy
  7. Concurrency cap
  8. Progress UX
  9. Failure modes and recovery
  10. Integrity verification
  11. Validation and security
  12. Drag-and-drop UX
  13. When to use a library
  14. Telemetry
  15. What a senior is expected to say
  16. Cross-references
  17. Further reading

Design: File Uploader with Resume

TL;DR

Drag-and-drop multi-file uploader that survives flaky networks, large files, and tab refresh. The senior topics: direct-to-storage (presigned URLs, not your server), chunked / multipart for resilience, resumable protocol (S3 multipart or tus), concurrency cap, per-file progress + cancel, integrity verification, and the security perimeter in the presign step.

Requirements to clarify

  • Max file size. 10MB photos? 5GB videos? Drives chunking strategy.
  • Connection profile. Mobile / hotel Wi-Fi (flaky) vs office (stable)?
  • File count per session. 1 at a time, or “drop 500 photos”?
  • Resume across tab refresh / day later? Yes → need persistent state and server-side upload sessions.
  • Storage destination. S3 (multipart upload API) / GCS / self-hosted (tus)?
  • Post-upload processing. Trigger Lambda for thumbnails? Notify the user when done?
  • Privacy / signed download. Public after upload, or signed-URL only?

Architecture

text
[Client]
   │ 1. Validate file (client-side)
   ├──▶ [API] /uploads/init   (auth, validate, generate UploadId + presigned URLs per chunk)
   │         │
   │         └──▶ [Storage backend creates multipart upload session]

   │ 2. PUT each chunk to presigned URL (in parallel, concurrency cap)
   ├──▶ [Storage (S3 / GCS / tus server)]

   │ 3. Notify completion with chunk ETags
   ├──▶ [API] /uploads/complete  (server calls CompleteMultipartUpload, optionally triggers post-processing)

   └──▶ User sees confirmation

Server is never on the data path. The bytes go directly from browser to S3.

See File Uploads — Multipart, Presigned S3, Chunked, Resumable for the protocol details (multipart, presigned, tus).

API contract

text
POST /api/uploads/init
  { "filename": "video.mp4", "size": 5_368_709_120, "contentType": "video/mp4", "checksum": "sha256:..." }
→ {
    "uploadId": "u_abc",
    "key": "uploads/u_ada/2026/05/video.mp4",
    "partSize": 8_388_608,                              // 8 MB
    "parts": [
      { "partNumber": 1, "url": "https://s3...?...&partNumber=1", "expiresAt": "..." },
      { "partNumber": 2, "url": "...", "expiresAt": "..." },
      ...
    ]
  }

PUT to each part URL → response includes ETag header (must store)

POST /api/uploads/:uploadId/complete
  { "parts": [{ "partNumber": 1, "etag": "..." }, ...] }
→ { "fileId": "f_123", "url": "https://cdn/.../video.mp4" }

POST /api/uploads/:uploadId/abort
→ {}     // cancel the S3 multipart upload, clean up

The server validates size and contentType against an allow-list before signing — that’s the security boundary, not the client.

Client state per file

ts
type UploadFile = {
  id: string;                        // local UUID
  file: File;
  status: "queued" | "uploading" | "paused" | "done" | "failed" | "cancelled";
  uploadId?: string;                 // server-issued
  key?: string;
  parts: PartState[];                // per-chunk state
  progress: number;                  // 0..1 derived from parts
  error?: string;
};

type PartState = {
  partNumber: number;
  url: string;
  expiresAt: string;
  etag?: string;
  status: "pending" | "uploading" | "done" | "failed";
  attempts: number;
};

Persisted to IndexedDB so a refresh/quit can resume. The File reference can’t be serialized — the user has to re-pick the file to resume (browsers don’t allow durable file handles, except via the experimental File System Access API). The recovered IDB state lets the UI say “you had a video at 60% — re-add it to resume.”

Chunking strategy

  • Min part size (S3): 5 MiB except the last part.
  • Max parts (S3): 10,000.
  • A 50 GB file at 5 MiB parts = 10K parts (boundary). Pick a part size proportional to file size: partSize = max(8 MiB, ceil(size / 9500)).
  • Larger parts = fewer requests, larger blast radius on a single failure. 8-16 MiB is the sweet spot for most files.

Concurrency cap

Upload N parts in parallel; cap at 4-6.

ts
async function uploadParts(file: UploadFile, concurrency = 4) {
  const queue = file.parts.filter(p => p.status === "pending");
  const workers = Array.from({ length: concurrency }, async function worker() {
    while (queue.length) {
      const part = queue.shift()!;
      try { await uploadOnePart(file, part); }
      catch (e) { part.status = "failed"; part.attempts++; /* requeue per retry policy */ }
    }
  });
  await Promise.all(workers);
}

Why cap: 100 parallel PUTs saturate the browser’s 6-per-origin (HTTP/1.1) limit, exhaust bandwidth, increase per-part latency, and trigger S3 throttling. 4-6 is the published sweet spot.

Progress UX

  • Per-part progress via XMLHttpRequest.upload.onprogress (not fetch, which is unreliable — see File Uploads — Multipart, Presigned S3, Chunked, Resumable).
  • Per-file progress = sum of part bytes uploaded / total bytes.
  • Aggregate progress = sum across all files in queue.
  • ETA = remaining bytes / recent throughput (compute over a rolling window so a momentary stall doesn’t lie).
  • Pause / Resume / Cancel per file (AbortController for in-flight requests; queue manipulation for queued).

Failure modes and recovery

  • Part fails (network error, 5xx) → retry that part with exponential backoff + jitter (cap at e.g. 5 attempts). Don’t restart the whole file. See Retries, Backoff, and Idempotency (from the Frontend).
  • Presigned URL expired mid-upload → ask the server for a fresh URL for that part (POST /uploads/:id/parts/:n/resign) and retry.
  • Tab refresh → IDB has the upload state; user re-picks the file; client compares file size + (optionally) hash; if it matches, resume from parts where status !== "done".
  • Network drop → all in-flight parts fail; on reconnect, retry the failed parts.
  • Full upload session expires (some servers expire multipart uploads after N hours) → restart with a fresh init.
  • Abort → client calls /uploads/:id/abort so S3 doesn’t keep paying for the orphan parts. Set a bucket lifecycle policy as a backstop: “Abort incomplete multipart uploads after 7 days.”

Integrity verification

  • Client computes the file SHA-256 (using crypto.subtle.digest on the File’s ArrayBuffer — stream it for big files via ReadableStream) and sends with init.
  • Server can verify by checking x-amz-meta-checksum or by retrieving and hashing post-upload. For mission-critical files, this catches corruption (truncated, bit-flipped) before the upload is marked done.
  • Per-part checksums — S3 supports Content-MD5 per part; client computes and sends with each PUT. S3 rejects on mismatch.

Validation and security

  • Client-side: extension allow-list, max size, magic-byte sniff for the file’s actual type (don’t trust extension).
  • Server-side (presign): re-validate size, content type allow-list, scope key prefix per user (uploads/${userId}/...), short URL expiry.
  • Server-side (complete): actually fetch the object’s HEAD and verify the stored size + content type match what was promised. The client can lie at PUT time; this is the last checkpoint.
  • Post-upload scan: anti-malware scan via S3 event → Lambda → ClamAV or commercial service before exposing.

See Frontend Security — Senior Interview Prep for the broader file-upload security checklist.

Drag-and-drop UX

  • Drop zone visible always; highlights on dragenter.
  • File picker fallback (<input type="file" multiple>) for keyboard / mobile.
  • Reject files that fail client-side validation with an inline error (don’t fail silently).
  • Show a queue with each file’s state inline.
  • Don’t onDragOver without preventDefault() — the default behavior of dropping a file onto a page is to navigate to it.
tsx
<div
  onDragOver={(e) => { e.preventDefault(); setHover(true); }}
  onDragLeave={() => setHover(false)}
  onDrop={(e) => { e.preventDefault(); setHover(false); enqueue([...e.dataTransfer.files]); }}
>
  Drop files here or <input type="file" multiple onChange={(e) => enqueue([...e.target.files!])} />
</div>

When to use a library

  • Uppy — full uploader UI with provider plugins (Google Drive, Dropbox), tus, S3 multipart, progress, retry. The default for “I need this in production tomorrow.”
  • tus-js-client — protocol implementation; bring your own UI.
  • @aws-sdk/lib-storage — S3 multipart upload in the browser; lower level than Uppy.

A senior answer mentions one of these by name and explains why hand-rolling is rarely worth it.

Telemetry

  • p99 upload latency per MB.
  • Retry rate per part.
  • Resume rate (% of uploads that experienced a resume).
  • Abort rate (% of users who cancel mid-upload).
  • Avg parts per file (ties to chunk-size choice).

What a senior is expected to say 6

  • “Direct-to-S3 with presigned URLs; server never on the data path. Multipart upload for anything >100MB or any flaky network.”
  • “Per-part retries with exponential backoff. URL re-signing for parts whose presigned URL expires mid-upload.”
  • “Concurrency cap at 4-6 parallel parts — beyond that you fight browser connection limits and S3 throttling, with no benefit.”
  • “Resumability requires persisted upload-session state in IndexedDB. File objects can’t be re-attached durably; the user re-picks the file, we verify size+hash, resume from incomplete parts.”
  • “Security boundary is the presign endpoint — that’s where size, type, and key prefix are validated. Plus a post-upload verification of stored metadata.”
  • “For production, I’d use Uppy or tus-js-client unless we have an unusual reason to hand-roll.”

Cross-references

Further reading