Skip to the solution
MediumSystem Design14 min read

Resumable browser uploads to S3 — presigned multipart with checksums?

614 views

The problem

Question presented to candidate: "Users upload files of up to 50 GB from the browser. We do not want the bytes to pass through our API servers, uploads must survive a flaky connection or a page reload, and we need proof that what landed in S3 is exactly what the user picked. Walk me through the flow between browser, backend and S3."

What a strong answer should cover:

  • Direct-to-S3: the backend keeps the AWS credentials, starts the multipart upload, and hands the browser presigned URLs per part; the bytes go browser to S3, the backend only handles small JSON calls
  • Per-part integrity: the browser computes each part's SHA-256 (or CRC) and sends it as the checksum header that S3 verifies (a corrupted part fails with BadDigest); the composite checksum at Complete gives end-to-end proof
  • Resume: persist uploadId and file identity (name, size, last-modified) in localStorage or the backend; after a reload, the backend calls ListParts, the browser re-hashes its slices to confirm it is the same file, and uploads only the missing parts
  • Presigned URL scope and expiry: short TTL (minutes), one URL per part number, re-issue on expiry; SigV4 presigned URLs last at most 7 days and die with the signing credentials
  • Browser specifics: CORS must allow PUT and expose the ETag header, or the browser cannot read part ETags; parallelism limits and memory (slice the File, do not load it)
  • Server-side bookkeeping: authorisation before presigning, an uploads table, Complete called by the backend, and a lifecycle rule that aborts abandoned uploads

Clarifying questions expected:

  • Maximum file size and typical network (mobile, office)?
  • Must resume work across devices or only after a reload on the same browser?
  • What happens after upload: virus scan, transcoding, making it visible to other users?

Code / implementation expected: Optional — the endpoint contract and the browser loop; the example simulates presigning, checksum checks and resume.

  • s3
  • presigned-url
  • checksum

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 · 12 sections

Read the code

Presigned part URLs with per-part SHA-256: corrupted part, misused URL, tab reload resume, expired URL, composite checksum, lifecycle abort
// Browser -> S3 multipart with presigned part URLs and per-part SHA-256, simulated in one process.
const crypto = require("crypto");
const sha = (b) => crypto.createHash("sha256").update(b).digest();
const b64 = (b) => b.toString("base64");
const SECRET = "backend-signing-key";          // stands for the AWS credentials only the backend holds
let now = 0;                                     // fake clock, seconds

// Backend: signs (uploadId, partNumber, checksum, expiry). Stands for SigV4 query-string signing.
function presign(uploadId, part, checksum, ttl) {
  const exp = now + ttl;
  const msg = `PUT\n${uploadId}\n${part}\n${checksum}\n${exp}`;
  return { uploadId, part, checksum, exp, sig: crypto.createHmac("sha256", SECRET).update(msg).digest("hex") };
}

// Fake S3 part endpoint: checks signature, expiry, then that the body matches the checksum header.
const parts = new Map();                          // uploadId -> Map(part -> {checksum, size})
function putPart(url, sentPart, header, body) {
  const msg = `PUT\n${url.uploadId}\n${sentPart}\n${header}\n${url.exp}`;
  const sig = crypto.createHmac("sha256", SECRET).update(msg).digest("hex");
  if (sig !== url.sig) return "403 SignatureDoesNotMatch";
  if (now > url.exp) return "403 AccessDenied (Request has expired)";
  if (b64(sha(body)) !== header) return "400 BadDigest";
  if (!parts.has(url.uploadId)) parts.set(url.uploadId, new Map());
  parts.get(url.uploadId).set(sentPart, { checksum: header, size: body.length });
  return "200 ETag " + crypto.createHash("md5").update(body).digest("hex").slice(0, 8);
}

// The "file" the user picked: 40,000 bytes split into 10,000-byte parts (stand for 10 MiB parts).
const file = Buffer.alloc(40000);
for (let i = 0; i < file.length; i++) file[i] = (i * 7 + (i >> 9)) & 255;
const PART = 10000;
const slice = (f, n) => f.subarray((n - 1) * PART, n * PART);
const sums = [1, 2, 3, 4].map((n) => b64(sha(slice(file, n))));

console.log("== 1. Browser hashes each part, backend presigns (TTL 900 s) ==");
const uploadId = "upl-7f3a";
const urls = {};
for (let n = 1; n <= 4; n++) { urls[n] = presign(uploadId, n, sums[n - 1], 900); }
console.log(`part 1 sha256 ${sums[0]}`);
console.log(`part 1 -> ${putPart(urls[1], 1, sums[0], slice(file, 1))}`);

console.log("== 2. A byte flipped in transit ==");
const corrupt = Buffer.from(slice(file, 2)); corrupt[1234] ^= 1;
console.log(`part 2 corrupted -> ${putPart(urls[2], 2, sums[1], corrupt)}`);
console.log(`part 2 retried   -> ${putPart(urls[2], 2, sums[1], slice(file, 2))}`);

console.log("== 3. Misusing a URL ==");
console.log(`part 3 URL sent as part 4 -> ${putPart(urls[3], 4, sums[3], slice(file, 4))}`);
console.log(`part 3 URL, other checksum -> ${putPart(urls[3], 3, sums[3], slice(file, 4))}`);
console.log(`part 3 -> ${putPart(urls[3], 3, sums[2], slice(file, 3))}`);

console.log("== 4. Tab reloads at t=1000; uploadId was saved in localStorage ==");
now = 1000;
const listed = [...parts.get(uploadId).entries()].sort((a, b) => a[0] - b[0]);
console.log(`backend ListParts -> ${listed.map(([n, p]) => `${n}:${p.size}`).join(" ")}`);
const other = Buffer.from(file); other[5] ^= 255;  // the user re-picked a different file with the same name
const matches = (f) => listed.every(([n, p]) => b64(sha(slice(f, n))) === p.checksum);
console.log(`re-picked same file matches stored parts: ${matches(file)}`);
console.log(`re-picked edited file matches stored parts: ${matches(other)}`);
console.log(`old part 4 URL -> ${putPart(urls[4], 4, sums[3], slice(file, 4))}`);
urls[4] = presign(uploadId, 4, sums[3], 900);
console.log(`fresh part 4 URL -> ${putPart(urls[4], 4, sums[3], slice(file, 4))}`);

console.log("== 5. Complete: composite checksum = sha256(part digests) + -N ==");
const got = [...parts.get(uploadId).entries()].sort((a, b) => a[0] - b[0]);
const composite = (list) => b64(sha(Buffer.concat(list.map((c) => Buffer.from(c, "base64"))))) + "-" + list.length;
const server = composite(got.map(([, p]) => p.checksum));
const browser = composite(sums);
console.log(`server  ${server}`);
console.log(`browser ${browser}`);
console.log(`whole-file sha256 ${b64(sha(file))} (a different value)`);
console.log(`integrity verified end to end: ${server === browser}`);

console.log("== 6. Lifecycle rule: abort incomplete uploads after 7 days ==");
const pending = [{ id: "upl-7f3a", started: 0, done: true }, { id: "upl-91c2", started: 0, done: false, bytes: 30000 }, { id: "upl-a004", started: 6 * 86400, done: false, bytes: 10000 }];
now = 8 * 86400;
for (const u of pending.filter((u) => !u.done)) {
  const age = (now - u.started) / 86400;
  console.log(`${u.id}: ${age} days old, ${u.bytes} bytes -> ${age >= 7 ? "aborted, parts deleted" : "kept"}`);
}

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 30 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

How would you design a notification or chat system (fan-out, presence)?

Continue