Skip to the solution
MediumSystem Design14 min read

How does S3 multipart upload make 5TB uploads resumable and fast?

292 views

The problem

Question presented to candidate: "A customer needs to push a 5 TB video archive into S3 over a link that drops every few hours. A single PUT will not work. Explain how multipart upload works, why it makes the upload both resumable and fast, and what limits and costs you have to plan around."

What a strong answer should cover:

  • The protocol: CreateMultipartUpload returns an upload ID, UploadPart sends numbered parts independently (each returns an ETag), CompleteMultipartUpload stitches the listed parts into one object atomically, AbortMultipartUpload discards them
  • The limits and the arithmetic: parts of 5 MiB to 5 GiB (last part may be smaller), at most 10,000 parts, so a 5 TiB object needs parts of at least about 525 MiB; single PUT is capped at 5 GB; the maximum object size was raised from 5 TB to 50 TB in December 2025
  • Why it is resumable: each acknowledged part is durable on the server, so after a crash the client calls ListParts (or keeps its own record) and re-sends only the missing parts; retrying a part number simply overwrites it
  • Why it is fast: one TCP stream is limited by latency and window size, so many parts in parallel fill the network; the run shows 29.1 hours on one stream versus 1.2 hours with 24 parallel parts
  • Integrity: per-part ETags (MD5 for unencrypted parts) checked at Complete, the multipart ETag is not the MD5 of the whole object, and additional checksums (CRC64NVME by default, SHA-256 composite) for end-to-end verification
  • Cost hygiene: incomplete uploads keep their parts billed until aborted, so add a lifecycle rule with AbortIncompleteMultipartUpload

Code / implementation expected: Optional โ€” the protocol and the arithmetic matter most; the example simulates S3, crashes mid-upload and resumes with ListParts.

  • s3
  • multipart
  • uploads

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

14 min read ยท 13 sections

Read the code

Multipart upload on a simulated S3: part sizing for 5 TiB, parallel speed, crash and resume with ListParts, multipart ETag
// Multipart upload on a simulated S3: part sizing, parallel speed, resume after a crash, ETag.
const crypto = require("crypto");
const MiB = 1024 * 1024, GiB = 1024 * MiB, TiB = 1024 * GiB;
const md5 = (b) => crypto.createHash("md5").update(b).digest();

// 1. Part sizing: at most 10,000 parts, each 5 MiB..5 GiB (the last may be smaller).
function plan(size) {
  let part = Math.max(5 * MiB, Math.ceil(size / 10000 / MiB) * MiB);
  return { part, parts: Math.ceil(size / part) };
}
console.log("== 1. Part size planner (max 10,000 parts, min part 5 MiB) ==");
for (const [name, size] of [["100 MiB", 100 * MiB], ["5 GiB", 5 * GiB], ["5 TiB", 5 * TiB], ["48.8 TiB", 10000 * 5 * GiB]]) {
  const p = plan(size);
  console.log(`${name.padEnd(9)} -> part ${String(p.part / MiB).padStart(4)} MiB, ${String(p.parts).padStart(5)} parts`);
}

// 2. Speed: one TCP stream is capped (assume 50 MiB/s); parallel parts add up to the NIC (assume 1,200 MiB/s).
console.log("== 2. Time to upload 5 TiB ==");
for (const conns of [1, 8, 24, 64]) {
  const rate = Math.min(conns * 50, 1200);
  const hours = (5 * TiB / MiB) / rate / 3600;
  console.log(`${String(conns).padStart(2)} parallel parts -> ${String(rate).padStart(4)} MiB/s, ${hours.toFixed(1)} h`);
}

// 3. A fake S3 that keeps parts per uploadId until Complete or Abort.
class FakeS3 {
  constructor() { this.uploads = new Map(); this.objects = new Map(); this.next = 1; }
  create(key) { const id = "upl-" + this.next++; this.uploads.set(id, { key, parts: new Map() }); return id; }
  uploadPart(id, n, body) { const e = md5(body).toString("hex"); this.uploads.get(id).parts.set(n, { body, etag: e }); return e; }
  listParts(id) { return [...this.uploads.get(id).parts.entries()].sort((a, b) => a[0] - b[0]).map(([n, p]) => ({ n, etag: p.etag })); }
  complete(id, list) {
    const u = this.uploads.get(id);
    for (const { n, etag } of list) {
      const p = u.parts.get(n);
      if (!p || p.etag !== etag) return { error: `InvalidPart ${n}` };
    }
    const body = Buffer.concat(list.map(({ n }) => u.parts.get(n).body));
    const etag = md5(Buffer.concat(list.map(({ etag }) => Buffer.from(etag, "hex")))).toString("hex") + "-" + list.length;
    this.objects.set(u.key, { body, etag });
    this.uploads.delete(id);
    return { etag, bytes: body.length };
  }
}

// Deterministic 50,000-byte object; part size scaled down to 8,000 bytes (stands for 8 MiB).
const data = Buffer.alloc(50000);
for (let i = 0; i < data.length; i++) data[i] = (i * 31 + (i >> 7)) & 255;
const PART = 8000, N = Math.ceil(data.length / PART);
const partBody = (n) => data.subarray((n - 1) * PART, Math.min(n * PART, data.length));

const s3 = new FakeS3();
console.log("== 3. Upload, crash, resume ==");
const id = s3.create("videos/raw.mov");
console.log(`create -> ${id}, ${N} parts of ${PART} bytes (last ${data.length - (N - 1) * PART})`);
const flaky = new Set([3]);                   // part 3 fails once with a connection reset
let sent = 0;
for (let n = 1; n <= 5; n++) {                // the laptop sleeps after part 5
  sent += partBody(n).length;
  if (flaky.delete(n)) { console.log(`part ${n}: connection reset, will retry later`); continue; }
  s3.uploadPart(id, n, partBody(n));
}
console.log(`client crashed after sending ${sent} bytes`);
const have = s3.listParts(id);
console.log(`resume: ListParts -> [${have.map((p) => p.n).join(", ")}]`);
const haveSet = new Set(have.map((p) => p.n));
let resent = 0;
for (let n = 1; n <= N; n++) if (!haveSet.has(n)) { s3.uploadPart(id, n, partBody(n)); resent += partBody(n).length; }
console.log(`resume uploaded ${resent} bytes (${N - haveSet.size} parts), not ${data.length}`);
s3.uploadPart(id, 2, partBody(2));            // retrying a part number just overwrites it
const list = s3.listParts(id);
const bad = list.map((p) => (p.n === 4 ? { n: 4, etag: "0".repeat(32) } : p));
console.log("complete with a wrong ETag for part 4 ->", s3.complete(id, bad).error);
const done = s3.complete(id, list);
console.log(`complete -> ${done.bytes} bytes, ETag ${done.etag}`);
console.log(`MD5 of whole object  ${md5(data).toString("hex")} (not the ETag)`);
console.log(`stored object matches source: ${s3.objects.get("videos/raw.mov").body.equals(data)}`);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 46 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

What are blue-green and canary deployments?

Continue