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

  1. A destination that supports both: object storage exposes single PUT and multipart APIs.
  2. The ability to issue presigned URLs (single, or one per part) via direct-to-cloud upload patterns.
  3. 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.

Single PUT versus multipart upload trade-offs Top row shows one PUT request that restarts fully on failure; bottom row shows multipart parts where only a failed part re-sends. Retry granularity Single PUT one request โ€” fails โ€” resend ALL Multipart part 1 ok part 2 FAIL part 3 ok part 4 ok resend part 2 Single PUT re-sends the whole file; multipart re-sends only the failed part.
The core trade-off: a failed single PUT restarts everything, while multipart retries only the affected part.

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: file on the single PUT streams the file; the browser does not buffer the whole thing, so memory stays low even at 90 MB.
  • prefersMultipart keys off file size and navigator.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 ETag and posts them to a completeUrl; 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 MB PART_SIZE to 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.