Multipart vs Single-PUT for Files Under 100MB
For files under 100 MB, a single PUT is usually the right choice: it is one request, one round trip, and far less code โ reach for multipart only when you specifically need per-chunk retry granularity, parallelism on a fast link, or bounded client memory.
This article is part of handling large file size limits within upload fundamentals and browser APIs. It is a decision guide, not a tutorial on either mechanism.
When to use this approach
- You are sizing the upload strategy for files that comfortably fit under 100 MB.
- You are weighing implementation complexity against resilience on real networks.
- You upload directly to object storage with S3 presigned URL workflows and want to know whether one presigned PUT suffices.
Prerequisites
- A destination that supports both: object storage exposes single PUT and multipart APIs.
- The ability to issue presigned URLs (single, or one per part) via direct-to-cloud upload patterns.
- TypeScript with
lib: ["DOM", "ES2022"].
The comparison
| Factor | Single PUT | Multipart |
|---|---|---|
| Requests | 1 | 3 + (initiate, N parts, complete) |
| Round-trip latency | Lowest | Higher (extra initiate/complete hops) |
| Retry granularity | Whole file restarts on failure | Only the failed part re-sends |
| Parallelism | None | Parts upload concurrently |
| Client memory | Streams one body; low if not buffered | One part at a time; bounded |
| Code complexity | Trivial | Tracks parts, ETags, completion |
| Presigned URLs | One | One per part + complete call |
| S3 part-size floor | n/a | 5 MB minimum per non-final part |
| Best fit (<100 MB) | Stable links, simple apps | Flaky networks, fast pipes, resumability |
The decisive variables are network reliability and how expensive a failed retry is. On a stable connection a 30 MB single PUT finishes in one trip; if it fails you re-send 30 MB. On a flaky mobile link, multipartโs ability to re-send only the 5 MB part that failed can be the difference between success and an endless restart loop.
Implementation
A single helper that picks the strategy by size and network hint keeps the decision in one place. Under the threshold (and on a non-cellular link) it does one PUT; otherwise it falls back to multipart by slicing the file.
export interface PutTarget {
url: string; // a presigned single-PUT URL
}
export interface MultipartTarget {
partUrls: string[]; // presigned URL per part
completeUrl: string; // your endpoint to finalize and collect ETags
}
const MULTIPART_THRESHOLD = 100 * 1024 * 1024; // 100 MB
const PART_SIZE = 5 * 1024 * 1024; // 5 MB โ S3 minimum
function prefersMultipart(file: File): boolean {
const conn = (navigator as Navigator & { connection?: { effectiveType?: string } }).connection;
const slowLink = conn?.effectiveType === "2g" || conn?.effectiveType === "3g";
return file.size >= MULTIPART_THRESHOLD || (slowLink && file.size > PART_SIZE);
}
async function singlePut(file: File, target: PutTarget): Promise<void> {
const res = await fetch(target.url, {
method: "PUT",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file, // streamed, not buffered
});
if (!res.ok) throw new Error(`single PUT failed: HTTP ${res.status}`);
}
async function multipartPut(file: File, target: MultipartTarget): Promise<void> {
const parts: { partNumber: number; etag: string }[] = [];
for (let i = 0; i < target.partUrls.length; i++) {
const start = i * PART_SIZE;
const end = Math.min(start + PART_SIZE, file.size);
const res = await fetch(target.partUrls[i], { method: "PUT", body: file.slice(start, end) });
if (!res.ok) throw new Error(`part ${i + 1} failed: HTTP ${res.status}`);
const etag = res.headers.get("ETag");
if (!etag) throw new Error(`part ${i + 1} returned no ETag`);
parts.push({ partNumber: i + 1, etag });
}
const done = await fetch(target.completeUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parts }),
});
if (!done.ok) throw new Error(`complete failed: HTTP ${done.status}`);
}
export async function uploadAdaptive(
file: File,
single: PutTarget,
multi: MultipartTarget,
): Promise<void> {
if (prefersMultipart(file)) {
console.log("strategy: multipart");
await multipartPut(file, multi);
} else {
console.log("strategy: single PUT");
await singlePut(file, single);
}
}
Line-by-line on the critical parts
body: fileon the single PUT streams the file; the browser does not buffer the whole thing, so memory stays low even at 90 MB.prefersMultipartkeys off file size andnavigator.connection.effectiveType, so a 40 MB file on 3G still gets the resilient path while the same file on Wi-Fi takes the cheap single PUT.- Multipart collects each partโs
ETagand posts them to acompleteUrl; S3 finalizes the object only after the complete call, so a dropped completion leaves an incomplete upload to abort or expire. file.slice(start, end)is the same byte-range slicing used for any chunked upload, with a 5 MBPART_SIZEto satisfy the S3 minimum.
For sub-100 MB files the table tilts toward the single PUT in most production apps: one presigned URL, no part tracking, no completion step to leak. Use multipart below the threshold only when the network or memory profile demands it.
Configuration gotchas
Multipart parts under 5 MB are rejected. S3 requires every part except the last to be at least 5 MB. A 4 MB part size on a 30 MB file fails at completion. Keep PART_SIZE >= 5 * 1024 * 1024.
Missing ETag header in the browser. S3 must expose ETag via CORS (ExposeHeaders: ["ETag"]) or res.headers.get("ETag") returns null and completion fails. Configure the bucket CORS before relying on it.
Single PUT Content-Type mismatch. If the presigned URL was signed with a specific Content-Type, the PUT must send the same value or S3 returns 403 SignatureDoesNotMatch. Sign and send identical types.
Incomplete multipart uploads accrue storage cost. A failed completion leaves uploaded parts billed until aborted. Add an S3 lifecycle rule to abort incomplete multipart uploads, or call AbortMultipartUpload on error.
Testing / verification
Confirm the chosen strategy and a single PUTโs success:
const file = new File([new Uint8Array(30 * 1024 * 1024)], "clip.mp4", { type: "video/mp4" });
console.assert(!prefersMultipart(file), "30MB on a fast link should pick single PUT");
const big = new File([new Uint8Array(120 * 1024 * 1024)], "movie.mp4", { type: "video/mp4" });
console.assert(prefersMultipart(big), "120MB should pick multipart");
console.log("strategy selection verified");
# Verify a single presigned PUT lands the object.
curl -i -X PUT --upload-file ./clip.mp4 \
-H "Content-Type: video/mp4" "$PRESIGNED_URL"
# Expect 200 with an ETag header.
FAQ
For a 50 MB file, single PUT or multipart?
On a stable connection, single PUT โ it is one round trip and a fraction of the code. Switch to multipart only if your users are on flaky networks where re-sending the whole 50 MB on a failure is too costly.
Does multipart upload faster for sub-100MB files?
Sometimes, on fast links, because parts upload in parallel. But it adds initiate and complete round trips, so for small files on ordinary connections the latency advantage often disappears.
What is the smallest part size for S3 multipart?
5 MB for every part except the last. Smaller non-final parts are rejected at CompleteMultipartUpload, so set your part size to at least 5 MB.
How do I avoid paying for failed multipart uploads?
Incomplete uploads keep their parts billed. Add an S3 lifecycle rule to abort incomplete multipart uploads after a day, or call AbortMultipartUpload in your error handler.