frontend / apis data fetching / 09_file_uploads.md

File Uploads — Multipart, Presigned S3, Chunked, Resumable

7 min read source

File Uploads — Multipart, Presigned S3, Chunked, Resumable

TL;DR

Three patterns, ordered by file size and reliability requirements:

  1. multipart/form-data to your API — simple, fine for small files (<10MB), routes the bytes through your server.
  2. Presigned URL straight to object storage — your API hands the client a short-lived signed URL; client PUTs directly to S3/GCS/Azure. Your server never touches the bytes. The right default for everything bigger than “a profile photo.”
  3. Chunked / resumable upload (multipart upload to S3, tus protocol) — split the file into parts, upload each (in parallel), resume failed parts without restarting. Required for large files (~100MB+) and flaky networks.

Plus: progress UX, drag-and-drop, validation before upload, and the security perimeter around the API that issues signed URLs.

Interview Q&A

Q: Show me the multipart/form-data upload to your own API.

A:

async function upload(file: File): Promise<{ id: string }> {
  const form = new FormData();
  form.append("file", file);
  form.append("description", "vacation photo");

  const res = await fetch("/api/uploads", { method: "POST", body: form });
  if (!res.ok) throw new Error(`upload failed: ${res.status}`);
  return res.json();
}

Do not set Content-Type yourself — the browser sets multipart/form-data; boundary=... correctly. Manually setting application/json is the canonical bug.

Limits to mind: the server body limit (Express ~100KB by default; configure for uploads), the reverse-proxy body limit (nginx client_max_body_size), and your serverless runtime’s payload limit (API Gateway 10MB, Lambda 6MB sync — large files can’t go this route on serverless).

Q: Why presigned URLs instead of routing through your API?

A: Three reasons:

  1. Your server bandwidth — direct-to-S3 means the bytes never traverse your origin. Major cost and bandwidth savings.
  2. Serverless limits — Lambda/API Gateway have payload caps; direct-to-S3 bypasses them entirely.
  3. Reliability — S3’s PUT is highly tuned; your origin is not.

Pattern:

// 1. Client asks server for a signed URL
const { url, key } = await fetch("/api/uploads/presign", {
  method: "POST",
  body: JSON.stringify({ filename: file.name, contentType: file.type }),
}).then(r => r.json());

// 2. Client PUTs directly to S3
await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });

// 3. Client tells server "I'm done, here's the key"
await fetch("/api/uploads/finalize", {
  method: "POST",
  body: JSON.stringify({ key }),
});

Server-side (presign), Python boto3 example:

url = s3.generate_presigned_url(
    "put_object",
    Params={"Bucket": bucket, "Key": key, "ContentType": content_type},
    ExpiresIn=300,                                # 5 minutes
)

The signature binds the URL to the bucket+key+content-type+expiry — the client can’t upload to a different key or change the content type. Validation belongs in the presign step, not in the PUT.

Q: Where do you enforce file-size and type limits?

A: Multiple layers (defense in depth):

  • Client side, pre-upload — quick UX feedback (reject 1GB videos before the user starts).
  • Presign endpoint — validate contentType and contentLength against your allow-list before signing. Refuse to sign if invalid.
  • S3 bucket policy — can enforce Content-Length ranges and content-type prefixes via conditions in the signed URL.
  • Post-upload server hook — on finalize, fetch the object’s headers and verify the actual size/type match what was promised. Without this, the client can lie at PUT time.

Client-side validation alone is not a security boundary — it’s UX. Always re-validate server-side.

Q: How do you show upload progress?

A: fetch does not expose upload progress (it’s been broken/half-supported for years). The reliable options:

  1. XMLHttpRequest — old but xhr.upload.onprogress works:
    const xhr = new XMLHttpRequest();
    xhr.open("PUT", url);
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) setProgress(e.loaded / e.total);
    };
    xhr.send(file);
  2. fetch + ReadableStream for downloads (response progress) works fine, but for upload progress, XHR is still the answer in most browsers. Streams API spec for Request body progress (duplex: "half" etc.) is rolling out but uneven.
  3. Library: axios, tus-js-client, Uppy — all give you progress + retry + chunking out of the box.

Q: When do you need chunked / multipart uploads?

A:

  • Files > ~100MB.
  • Networks that drop mid-upload (mobile, hotel Wi-Fi).
  • “Resume from where it stopped” UX.

Strategy: split into 5-100MB parts, upload them (optionally in parallel), assemble server-side.

S3 Multipart Upload is the canonical implementation. Flow:

1. POST /api/uploads/init       → server creates S3 multipart upload, returns UploadId + N presigned URLs (one per part)
2. PUT  each part to S3 in parallel → S3 returns an ETag per part
3. POST /api/uploads/complete   → client sends part numbers + ETags; server calls S3 CompleteMultipartUpload

If a part fails, retry just that part. If the user closes the tab, the partial upload sits in S3 (charged) until cleaned up — set a bucket lifecycle policy: Abort incomplete multipart uploads after 7 days.

Q: What is tus and when do you use it?

A: tus is an open protocol for resumable uploads over HTTP — sequence of PATCH requests with Upload-Offset headers. The client resumes from the last acknowledged offset.

POST /files                       → returns location URL with upload ID
PATCH /files/abc  Upload-Offset: 0 → write some bytes, server returns new offset
PATCH /files/abc  Upload-Offset: N → write more bytes
HEAD  /files/abc                  → client checks current server offset on resume

Pick tus over S3 multipart when you self-host storage or need a single uniform resumable-upload protocol across multiple backends (S3 + on-prem + GCS). S3 multipart is simpler if you’re already on S3.

Q: How do you handle “user dragged 200 files into the browser”?

A:

  • Concurrency cap. Upload 3-5 files at once max; queue the rest. Otherwise you saturate the browser’s 6-connection limit per origin (on HTTP/1.1) or hammer S3.
  • Per-file progress + cancel — each file gets its own row with a cancel button (tied to an AbortController).
  • Aggregate progress — total bytes uploaded / total bytes queued, ETA.
  • Recover failed individually — one failed file shouldn’t take down the queue.
async function uploadQueue(files: File[], concurrency = 4) {
  const queue = [...files];
  const workers = Array.from({ length: concurrency }, async function worker() {
    while (queue.length) {
      const file = queue.shift()!;
      try { await upload(file); } catch (e) { recordError(file, e); }
    }
  });
  await Promise.all(workers);
}

Q: Security pitfalls?

A:

  • Open presign endpoint — anyone authenticated can upload anywhere — set tight key prefixes per user (uploads/${userId}/...) and reject other paths in the signing logic.
  • No content-type lock — user signs as image/png, uploads an HTML file, you serve it — XSS. Bind Content-Type in the signature.
  • No size limit in the signature — user uploads a 50GB file, bills you. Enforce Content-Length ranges.
  • Trusting the file extensionevil.png.exe. Sniff magic bytes server-side or validate via a content-type allow-list.
  • Public-read uploads — the bucket should default to private; serve via signed GET URLs or via CloudFront with origin access control.
  • Path traversal in filename — never let filename from the client become the object key directly. Generate a UUID, store the original name in metadata.

Gotchas / edge cases

  • fetch upload progress is unreliable cross-browser — use XMLHttpRequest for progress, period.
  • CORS for direct-to-S3 — the bucket needs CORS configured to allow PUT from your origin, and to expose ETag (otherwise the client can’t read it for multipart completion).
  • Pre-flight on PUT — non-simple Content-Type triggers a CORS preflight (OPTIONS); bucket CORS must allow it.
  • Browser memory — reading a 1GB file into a FormData blows up. Stream from the File blob directly to fetch/xhr; don’t arrayBuffer() it.
  • File objects can become invalid if the user moves/deletes the source file before upload — handle the read error.
  • Mobile uploads can be paused by the OS when the tab backgrounds — only resumable protocols recover cleanly.

What a senior is expected to say

  • “Three patterns: multipart-to-server for small files; presigned URL direct-to-S3 as the default; multipart/resumable (S3 multipart or tus) for big or flaky uploads.”
  • “I validate file size and type in the presign step — that’s where the security boundary lives. Client-side validation is for UX.”
  • “I lock content-type and size in the signature, scope key prefixes per user, and configure a bucket lifecycle to clean up aborted multipart uploads.”
  • fetch doesn’t give reliable upload progress — I use XMLHttpRequest or a library.”
  • “Concurrency cap on bulk uploads (4-5 in flight) — anything more saturates browser connections and S3 throttles you.”

Cross-references

Further reading