Multipart vs Single-PUT for Files Under 100MB
For files under 100 MB, a single PUT is usually the right choice: one request, one round trip, and roughly a tenth of the code — reach for multipart only when per-part retry granularity, parallelism on a high-bandwidth link, or bounded client memory actually buys you something measurable.
This article sits inside handling large file size limits, part of upload fundamentals and browser APIs. It is a decision guide with the arithmetic attached, not a tutorial on either mechanism — the parent guide covers how to drive a full multipart session end to end.
When to use this approach
- You are sizing the upload strategy for a payload that comfortably fits under 100 MB — phone video clips, PDFs, RAW photos, design files, database exports.
- You are weighing implementation complexity and per-request cost against resilience on real, lossy networks.
- You upload straight to object storage using S3 presigned URL workflows and need to know whether one signed PUT is enough, or whether you owe your users a part-level retry path.
If the file might exceed 100 MB, or the length is unknown when the request starts, the decision is already made for you: go straight to best practices for handling 500MB file uploads.
Prerequisites
- A destination that exposes both APIs. S3, R2, GCS (XML API) and Azure Block Blob all offer a single-shot write and a part-based session.
- A signing endpoint that can mint either one presigned PUT or one presigned URL per part, following direct-to-cloud upload patterns.
- Bucket CORS with
ExposeHeaders: ["ETag"], or the browser cannot read the part identifiers it needs. - TypeScript 5.x with
lib: ["DOM", "ES2022"]. No SDK in the browser — everything below isfetch.
The comparison at a glance
| Factor | Single PUT | Multipart |
|---|---|---|
| HTTP requests (50 MB) | 1 | 3 + N parts (13 at 5 MB parts) |
| Round trips before bytes flow | 1 signing hop | Signing hop + CreateMultipartUpload |
| Retry granularity | Whole object restarts | Only the failed part re-sends |
| Parallelism | One connection | N parts, bounded concurrency |
| Peak client memory | Streamed by the browser | concurrency × part size |
| Presigned URLs to issue | 1 | N + a complete and an abort call |
| Part-size floor | n/a | 5 MiB for every part but the last |
| ETag semantics | MD5 of the object | md5-of-md5s-N, not a content hash |
| Server state to clean up | None | An upload ID that bills until aborted |
| Best fit under 100 MB | Stable links, simple apps | Lossy mobile links, resumability |
The decisive variables are network reliability and the price of a failed attempt. On a stable connection a 50 MB single PUT finishes in one trip; if it dies at 92 % you re-send all 50 MB. On a congested mobile link, re-sending only the 10 MB part that failed is the difference between eventual success and a restart loop the user abandons.
What multipart actually costs you
Most comparisons stop at “multipart is more resilient”. That is true and incomplete. Below 100 MB the overheads are large relative to the payload, so it is worth pricing them.
The extra round trips
A single PUT needs one hop to your signing endpoint and one hop to the bucket. A multipart session needs a CreateMultipartUpload before any byte moves, and a CompleteMultipartUpload after the last one — and because neither call should be exposed to the browser as a raw signed request, both usually travel through your API, adding a server-to-S3 leg on each end.
On a 60 ms round trip that is roughly 300 ms of pure protocol overhead before you count the payload, plus one CompleteMultipartUpload that S3 answers in 150–800 ms depending on part count. For a 40 MB file moving at 5 MB/s the body takes eight seconds, so the overhead is about 12 %. For a 6 MB file it is closer to half the wall clock.
Why parallel parts rarely pay off below 100 MB
The intuition that “N parallel parts are N times faster” only holds when a single connection cannot saturate the path. Two things get in the way at this size.
First, the bandwidth-delay product. A 100 Mbps path with an 80 ms round trip holds about 1 MB in flight; a single TCP connection reaches that window after roughly ten round trips of slow start, or about 800 ms. An 8 MB part on a fresh connection spends a real fraction of its life ramping up, and every part you add pays that tax again unless the connections are reused.
Second, protocol multiplexing. The S3 REST endpoint negotiates HTTP/1.1, so a browser opens up to six sockets per origin and genuine parallelism is available. Put the same bucket behind a CDN that negotiates HTTP/2 and all your part PUTs are multiplexed onto one connection sharing one congestion window — the parts interleave, the total throughput does not change, and you have simply made the progress bar smoother. Measure before you assume; the numbers in direct S3 uploads vs proxy uploads show how much the path itself dominates.
Request charges and completion latency
S3 bills PUT, COPY, POST and LIST at $0.005 per 1,000 requests in us-east-1. A 50 MB single PUT is one request. The same file as 5 MB parts is 13 (create, eleven parts, complete). Per upload the difference is a rounding error — 0.0055 cents. At a million uploads a day it is $5 versus $65, before the extra signing CPU and the ~9 KB of presigned URLs you now serialise into every plan response.
The break-even point
Model the failure as a hazard per request rather than per byte, because that is how mobile networks actually behave: a radio handover or a NAT rebind kills the socket regardless of how many bytes are left. Let q be the probability that one 5 MB request dies. A 50 MB single PUT is exposed for ten times as long, so its failure probability is 1 − (1 − q)¹⁰, and the expected bytes on the wire are 50 / (1 − P) for single PUT against 50 / (1 − q) for multipart.
At office-Wi-Fi loss (0.2 %) the single PUT moves 51 MB and multipart 50.1 MB — a 2 % difference that no user perceives. At 2 % it is 61 MB against 51 MB. At 10 %, typical of a train carriage or a congested stadium, the single PUT expects 143 MB and multipart 56 MB, and worse, the single PUT’s variance explodes: one upload in three needs a second full attempt, and the retry is exactly as likely to fail. That is the number to put in front of a product owner, not “multipart is more robust”.
Instrument it before you choose. Log the HTTP status and the byte offset reached on every failed upload for a week; if fewer than one attempt in fifty dies mid-body, multipart is complexity you are paying for and not using.
A default policy you can defend
Note the third question. Multipart survives a network failure but not a page failure: the upload ID and the collected ETags live in a JavaScript variable, and a refresh loses them. If your users close the tab mid-upload, you need persisted state and a protocol built for it — see building a resumable upload flow with tus.
Implementation
One module holds the whole decision: a strategy function, a single-PUT path, a bounded-concurrency multipart path with per-part retry, and an abort on the way out.
const MIB = 1024 * 1024;
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
export interface StrategyOptions {
thresholdBytes: number;
partSize: number;
concurrency: number;
maxAttempts: number;
lossyLinkTypes: readonly string[];
}
export const defaults: StrategyOptions = {
thresholdBytes: 100 * MIB,
partSize: 8 * MIB,
concurrency: 3,
maxAttempts: 4,
lossyLinkTypes: ["slow-2g", "2g", "3g"],
};
export interface SinglePlan { kind: "single"; url: string; contentType: string }
export interface MultipartPlan {
kind: "multipart";
uploadId: string;
partSize: number;
partUrls: string[];
completeUrl: string;
abortUrl: string;
}
export type UploadPlan = SinglePlan | MultipartPlan;
interface CompletedPart { PartNumber: number; ETag: string }
class HttpError extends Error {
constructor(readonly status: number, message: string) {
super(message);
this.name = "HttpError";
}
}
/** Decide before asking the server for URLs, so it signs only what you need. */
export function chooseStrategy(file: File, opts: StrategyOptions = defaults): "single" | "multipart" {
if (file.size === 0) return "single"; // a multipart session cannot complete with zero parts
if (file.size >= opts.thresholdBytes) return "multipart";
const nav = navigator as Navigator & { connection?: { effectiveType?: string } };
const lossy = opts.lossyLinkTypes.includes(nav.connection?.effectiveType ?? "");
return lossy && file.size > opts.partSize * 2 ? "multipart" : "single";
}
async function withRetry<T>(attempts: number, run: () => Promise<T>): Promise<T> {
let lastError: unknown = new Error("no attempt made");
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await run();
} catch (error) {
lastError = error;
// TypeError is how fetch surfaces a dropped socket; 5xx/429 are worth another go.
const retryable = error instanceof HttpError
? RETRYABLE_STATUS.has(error.status)
: error instanceof TypeError;
if (!retryable || attempt === attempts) break;
const backoff = Math.min(8000, 250 * 2 ** attempt) * (0.5 + Math.random() / 2);
await new Promise((resolve) => setTimeout(resolve, backoff));
}
}
throw lastError;
}
async function uploadParts(
file: File,
plan: MultipartPlan,
opts: StrategyOptions,
signal: AbortSignal,
): Promise<CompletedPart[]> {
const parts = new Array<CompletedPart>(plan.partUrls.length);
let cursor = 0;
const worker = async (): Promise<void> => {
while (cursor < plan.partUrls.length) {
const index = cursor++;
const start = index * plan.partSize;
const body = file.slice(start, Math.min(start + plan.partSize, file.size));
const etag = await withRetry(opts.maxAttempts, async () => {
const res = await fetch(plan.partUrls[index], { method: "PUT", body, signal });
if (!res.ok) throw new HttpError(res.status, `part ${index + 1}: HTTP ${res.status}`);
const tag = res.headers.get("ETag");
if (!tag) throw new Error(`part ${index + 1}: no ETag — bucket CORS must expose it`);
return tag;
});
parts[index] = { PartNumber: index + 1, ETag: etag };
}
};
const lanes = Math.min(opts.concurrency, plan.partUrls.length);
await Promise.all(Array.from({ length: lanes }, worker));
return parts;
}
export async function upload(
file: File,
plan: UploadPlan,
opts: StrategyOptions = defaults,
signal: AbortSignal = new AbortController().signal,
): Promise<string> {
if (plan.kind === "single") {
return withRetry(opts.maxAttempts, async () => {
const res = await fetch(plan.url, {
method: "PUT",
headers: { "Content-Type": plan.contentType },
body: file, // the browser streams this; it is never copied into a buffer
signal,
});
if (!res.ok) throw new HttpError(res.status, `single PUT: HTTP ${res.status}`);
return res.headers.get("ETag") ?? "";
});
}
try {
const parts = await uploadParts(file, plan, opts, signal);
const res = await fetch(plan.completeUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uploadId: plan.uploadId, parts }),
signal,
});
const payload = (await res.json()) as { etag?: string; error?: string };
// S3 can answer CompleteMultipartUpload with HTTP 200 and an error document,
// so your API must forward the body and you must read it.
if (!res.ok || payload.error || !payload.etag) {
throw new Error(`complete failed: ${payload.error ?? `HTTP ${res.status}`}`);
}
return payload.etag;
} catch (error) {
await fetch(plan.abortUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uploadId: plan.uploadId }),
keepalive: true,
}).catch(() => undefined);
throw error;
}
}
Line by line on the parameters that matter
body: fileon the single PUT hands the browser aBlob; it streams the bytes from disk and never materialises 90 MB in the JavaScript heap. That kills the most common argument for multipart at this size.chooseStrategyreturns"single"for zero-byte files deliberately. A multipart session with no parts fails at completion, and empty files show up more often than you expect from placeholder rows and failed exports.partSize: 8 * MIBrather than the 5 MiB floor. Eight megabytes keeps a 100 MB file at thirteen parts, comfortably under the 10,000-part ceiling, and gives TCP long enough per request to leave slow start.concurrency: 3bounds peak memory at three slices in flight — 24 MB — and stays well inside the six-socket per-origin limit so progress events keep arriving. The slicing itself is plain Blob.slice.withRetrytreatsTypeErroras retryable because that is whatfetchthrows for a dropped connection, and only retries the 4xx codes that are genuinely transient. Tune the schedule with exponential backoff for failed chunks.- The
catchblock fires the abort withkeepalive: trueso it still leaves the browser during a page unload.
Configuration reference
| Key | Type | Default | Effect |
|---|---|---|---|
thresholdBytes |
number | 104857600 |
Size at or above which multipart is forced regardless of link quality. |
partSize |
number | 8388608 |
Bytes per part. Must be ≥ 5 MiB for every part but the last; drives part count and peak memory. |
concurrency |
number | 3 |
Parts in flight. Peak heap ≈ concurrency × partSize. Above 6 the browser queues anyway. |
maxAttempts |
number | 4 |
Attempts per request. With the jittered schedule the worst case is about 15 s of waiting. |
lossyLinkTypes |
string[] | ["slow-2g","2g","3g"] |
navigator.connection.effectiveType values that flip a sub-threshold file to multipart. |
| URL TTL (server side) | seconds | 3600 |
Must exceed the slowest plausible upload; expiry mid-session yields 403 on later parts. |
ExposeHeaders (bucket CORS) |
string[] | ["ETag"] |
Without it res.headers.get("ETag") is null and completion is impossible. |
Configuration gotchas
Parts under 5 MiB are rejected at the end, not the start
Every part except the last must be at least 5,242,880 bytes. S3 accepts the small PUTs happily and only refuses at the finish line with HTTP 400 and EntityTooSmall: Your proposed upload is smaller than the minimum allowed size. You have already spent the bandwidth. Keep partSize at 8 MiB and assert it in the signing endpoint.
The ETag the browser cannot see
If the bucket CORS rule omits ExposeHeaders: ["ETag"], res.headers.get("ETag") returns null even though the PUT returned 200, and completion fails with InvalidPart: One or more of the specified parts could not be found. Fix the rule, not the client — the mechanics are in fixing CORS preflight errors on S3 uploads.
A signed Content-Type must be sent back verbatim
If the presigned URL was generated with a ContentType, the PUT must send exactly the same string — video/mp4 and video/mp4; charset=utf-8 are different signatures. The response is HTTP 403 with SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. This bites hardest when the browser omits the header and the SDK signed one; the trade-offs of signing a policy instead are covered in presigned POST vs presigned PUT.
CompleteMultipartUpload can fail with HTTP 200
S3 may send whitespace to hold the connection open while it assembles the object, then write an error document into an already-200 response: <Error><Code>InternalError</Code><Message>We encountered an internal error. Please try again.</Message></Error>. Any client that checks only res.ok records a success and loses the object. Parse the body server-side and surface a real status, as the code above assumes.
A multipart ETag is not a checksum
A single-PUT object’s ETag is the MD5 of its bytes. A multipart object’s is the MD5 of the concatenated part MD5s with -13 appended, so it cannot be compared against a hash computed on the client. If you verify integrity end to end, send an explicit x-amz-checksum-crc32c per part or compute the digest with file checksums in the browser using Web Crypto and store it as metadata.
Cloudflare R2 wants identical part sizes
R2 is stricter than S3: every part except the last must be exactly the same length, not merely above the floor. A final short part is fine, but a resumed session that changes partSize mid-flight fails. Pin the part size in the plan the server returns and never recompute it on the client.
Verification
Assert the strategy choice in a unit test, then prove the wire behaviour:
const clip = new File([new Uint8Array(30 * 1024 * 1024)], "clip.mp4", { type: "video/mp4" });
console.assert(chooseStrategy(clip) === "single", "30 MB on a fast link should be a single PUT");
const movie = new File([new Uint8Array(120 * 1024 * 1024)], "movie.mp4", { type: "video/mp4" });
console.assert(chooseStrategy(movie) === "multipart", "120 MB must be multipart");
const empty = new File([], "empty.txt", { type: "text/plain" });
console.assert(chooseStrategy(empty) === "single", "zero-byte files must not open a session");
console.log("strategy selection verified");
# 1. A single presigned PUT should land the object in one request.
curl -sS -o /dev/null -D - -X PUT --upload-file ./clip.mp4 \
-H "Content-Type: video/mp4" "$PRESIGNED_URL"
# Expect: HTTP/1.1 200 OK with ETag: "5f3a9c1e...". A bare 32-hex ETag and no
# trailing "-N" is proof the object was written as one part.
# 2. Nothing should be left half-uploaded after a test run.
aws s3api list-multipart-uploads --bucket uploads-prod \
--query 'Uploads[].{key:Key,id:UploadId,started:Initiated}'
# Expect: null. Every row here is storage you are paying for.
In DevTools, the Network panel is the fastest audit: a single PUT shows one row whose transferred size matches the file, while a multipart run shows the plan request, N part rows and the complete call. If the part rows do not overlap on the waterfall, your concurrency is not taking effect. Pair this with aborting uploads with AbortController to confirm cancellation tears down every in-flight part, and schedule cleanup with a rule for expiring incomplete multipart uploads automatically.
Frequently Asked Questions
For a 50 MB file, single PUT or multipart?
Single PUT, unless your telemetry says more than about 2 % of upload requests die mid-body. At that loss rate the expected bytes transferred are within 20 % of each other, and the single PUT costs you one request instead of thirteen and no session to clean up. Measure your own failure rate before adopting the complex path.
Does multipart upload faster for sub-100MB files?
Rarely, and only when the path has spare bandwidth a single connection cannot fill. It adds a create and a complete round trip up front, and if the bucket sits behind an HTTP/2 endpoint the parallel parts share one congestion window and finish in the same wall clock. On a 40 MB file over a 60 ms link, multipart is typically 300–800 ms slower.
What is the smallest part size S3 accepts?
5 MiB (5,242,880 bytes) for every part except the last, with a maximum of 10,000 parts per object. Undersized parts are only rejected at CompleteMultipartUpload, so the bandwidth is already spent when you find out. 8 MiB is a better default than the floor.
Can I switch strategies halfway through an upload?
No. The two APIs write different objects and share no state, so a failed single PUT cannot be resumed as a multipart session — you start again. This is why the strategy decision belongs at plan time, before the signing endpoint issues anything, and why an unknown content length forces multipart.
How do I stop paying for multipart uploads that never finished?
Call AbortMultipartUpload in your error path, as the module above does, and back it with an AbortIncompleteMultipartUpload lifecycle rule set to one or seven days. Parts from an abandoned session are billed at full storage rates and never appear in a ListObjects response, so nobody notices them until the invoice does.