File Uploads — Multipart, Presigned S3, Chunked, Resumable
TL;DR
Three patterns, ordered by file size and reliability requirements:
multipart/form-datato your API — simple, fine for small files (<10MB), routes the bytes through your server.- 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.” - Chunked / resumable upload (multipart upload to S3,
tusprotocol) — 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:
- Your server bandwidth — direct-to-S3 means the bytes never traverse your origin. Major cost and bandwidth savings.
- Serverless limits — Lambda/API Gateway have payload caps; direct-to-S3 bypasses them entirely.
- Reliability — S3’s
PUTis 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
contentTypeandcontentLengthagainst your allow-list before signing. Refuse to sign if invalid. - S3 bucket policy — can enforce
Content-Lengthranges 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:
XMLHttpRequest— old butxhr.upload.onprogressworks:const xhr = new XMLHttpRequest(); xhr.open("PUT", url); xhr.upload.onprogress = (e) => { if (e.lengthComputable) setProgress(e.loaded / e.total); }; xhr.send(file);fetch+ReadableStreamfor downloads (response progress) works fine, but for upload progress, XHR is still the answer in most browsers. Streams API spec forRequestbody progress (duplex: "half"etc.) is rolling out but uneven.- 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. BindContent-Typein the signature. - No size limit in the signature — user uploads a 50GB file, bills you. Enforce
Content-Lengthranges. - Trusting the file extension —
evil.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
filenamefrom the client become the object key directly. Generate a UUID, store the original name in metadata.
Gotchas / edge cases
fetchupload progress is unreliable cross-browser — useXMLHttpRequestfor 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-Typetriggers a CORS preflight (OPTIONS); bucket CORS must allow it. - Browser memory — reading a 1GB file into a
FormDatablows up. Stream from theFileblob directly tofetch/xhr; don’tarrayBuffer()it. Fileobjects 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.”
- “
fetchdoesn’t give reliable upload progress — I useXMLHttpRequestor a library.” - “Concurrency cap on bulk uploads (4-5 in flight) — anything more saturates browser connections and S3 throttles you.”
Cross-references
- AbortController for cancelling uploads: 05_abort_and_race_conditions.md
- Retries: 06_retries_backoff_idempotency.md
- AWS S3 (server side): ../../backend/19_cloud_aws/Storage/05_Amazon_Simple_Storage_Service/
- Frontend system design — file uploader worked example: ../14_frontend_system_design/
Further reading
- AWS S3 — Multipart Upload Overview: https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
- AWS S3 — Using presigned URLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html
tusprotocol: https://tus.io/protocols/resumable-upload- Uppy (full-featured uploader): https://uppy.io/