all_lessons / system_design / cases / C17 C17 / C44

Design large file upload and serving

A 10 GB upload over a home connection takes an hour. Networks drop, browsers close, laptops sleep — so the only sane design treats failure as the normal case and never makes the user start over.

Anchor: resumable multipart Control plane vs data plane Idempotent commit
First principle
Over a long transfer, the probability that something interrupts you approaches 1. You cannot prevent the interruption — you can only choose how much work it destroys. Design the unit of work so a failure throws away one chunk, not the whole file. Everything in this lesson — chunking, signed URLs, manifests, idempotent commits — falls out of that single decision: make the retry unit small, make every retry safe to repeat, and never let the app server stand between the client and the bytes.

This is the anchor case for resumable multipart upload, chunk dedup, and idempotent commit. Other cases lean on the machinery here: C14 (video transcode) and C15 (file sync) are consumers of an upload pipeline; C16 (object storage) provides the durable substrate underneath it. So we teach the upload protocol in full and let those cases reference it.

1. Why failure dominates the design

Start with the transfer time, because it sets the whole problem. Take a 10 GB file over a 20 Mbps uplink — a generous home connection. 10 GB = 80,000 Mb (using 8 bits/byte and 1000-based units), so the floor is:

80,000 Mb ÷ 20 Mbps = 4,000 s ≈ 67 min

Call it ~68 minutes in ideal conditions, longer in practice. That is not a request; it is a session that has to survive Wi-Fi blips, a closed lid, a roaming handoff, a process restart. Treat the whole upload as one atomic HTTP PUT and a single dropped packet at minute 66 costs the user the entire hour. That is the forcing function: the transfer is too long to be all-or-nothing.

The fix is to slice the file into independently-retryable parts. With 8 MB parts, a 10 GB file is:

10 GB ÷ 8 MB = 10,240 MB ÷ 8 MB = 1,280 parts

Now put a failure rate on it. Suppose each part PUT fails with probability p = 0.5% (a transient TCP reset, a 5xx from the store, a timeout). Expected failed parts over the whole upload:

1,280 × 0.005 = 6.4 parts ≈ 51 MB re-uploaded

So across a 10 GB upload you expect to re-send about 6–7 parts (~50 MB) — under 0.5% of the file — instead of restarting all 10 GB on the first hiccup. That is the entire economic argument for resumable chunking, in two numbers.

10 GB @ 20 Mbps
≈ 68 min
Parts @ 8 MB
1,280
Expected retries @ p=0.5%
≈ 6.4 parts
Re-uploaded on failure
~50 MB vs 10 GB
Invariant to protect
A completed file is published only when every part is present and its bytes verify against the manifest, and any operation the client retries — part PUT or complete — is idempotent. Resumability is worthless if a resumed-or-retried upload can publish a corrupt or half-assembled file. Correctness lives in the commit, not the transfer.

2. Clarify the contract

Treat the prompt as a product contract before a box diagram. The system must:

And explicitly what it need not do: it is not a transcoding pipeline (that is C14, downstream of commit), not a delta-sync engine (C15), and the app server is not a byte proxy. The app issues credentials and records metadata; the bytes flow client → object store directly. Naming these non-goals out loud is what keeps the design small.

3. Data model & API

The data model is the first architecture. Keep the API expressing the product operation, not the storage layout.

API / operationWhat it does
POST /uploads (create)Opens an upload session: returns an uploadId, target metadata, part size, and an expiration. Control plane only — no bytes.
GET /uploads/{id}/partsLists which part numbers + checksums are already durably stored — this is what makes resume possible.
PUT part (to signed URL)Uploads one numbered chunk directly to the object store. Keyed by (uploadId, partNumber) so a repeat is idempotent.
POST /uploads/{id}/completeClient sends the manifest (the part list + expected checksums). Server validates and atomically publishes.
GET /files/{id}/urlReturns a short-lived signed download URL (CDN-fronted for public/shareable content).

The session record is the spine:

uploadIdobjectKeypartSizeparts[]: {n, etag/checksum, size}state: open|completing|published|abortedexpiresAt

This is DDIA Ch. 4 (encoding) territory: the manifest — the ordered list of parts and their checksums — is the canonical encoding of "what this file is." Assembly and verification both read from it, so its integrity is the file's integrity.

4. Control plane vs data plane

The single most important structural decision: the app server never touches the file bytes. It runs the control plane — create the session, mint signed URLs, validate, publish. The data plane — the actual gigabytes — flows from client straight to the object store.

CONTROL PLANE (app servers — metadata only, no bytes) ┌──────────────────────────────────────────────────────────┐ ┌────────┐ (1) │ create session → uploadId, partSize, expiry │ │ CLIENT │ ────▶│ (2) issue SIGNED URLs for parts 1..N │ │ │ ◀────│ (3) list already-stored parts (resume) │ │ │ │ (5) complete → validate manifest + checksums → PUBLISH │ └───┬────┘ └───────────────────────────────┬──────────────────────────┘ │ │ records part metadata │ (4) PUT part k ─────────── DATA PLANE ──┼──────────────▶ ┌──────────────┐ │ (bytes go DIRECT to store, signed) │ │ OBJECT STORE │ └──────────────────────────────────────────────────────────▶│ parts + │ app never proxies a single byte │ manifest │ └──────────────┘ manifest assembly: parts[1..N] + checksums ──(validate)──▶ published object key

Why this split is non-negotiable: if the app proxied bytes, a few thousand concurrent 10 GB uploads would saturate app-server NICs and turn a stateless, cheap-to-scale tier into a bandwidth bottleneck — the exact failure the trade-off table below warns against. By handing the client a signed URL (a time-boxed, scoped credential to PUT one specific object key), the store does the heavy lifting and the app stays a thin metadata service. This is the same control/data-plane separation that lets C16's storage fleet scale independently of any front-end.

5. Linearized design — follow one upload through

  1. Create. Client calls create with file size and a content hash. Server allocates an objectKey, picks a part size, writes an open session with expiresAt, returns the uploadId. No bytes yet.
  2. Resume check. On (re)connect the client calls list parts. The server returns the part numbers already stored. The client uploads only the missing ones — this is the line that turns "restart 10 GB" into "send the 6 parts you lost."
  3. Upload parts (data plane). For each missing part the client PUTs the chunk to its signed URL, directly to the object store, including a per-part checksum. Parts go in parallel (say 4–8 at once) to fill the bandwidth-delay product. The store records each part under (uploadId, partNumber) and returns an ETag/checksum.
  4. Complete. Client calls complete with the full manifest. Server validates: all N parts present? each stored checksum match the manifest? If yes, it instructs the store to assemble and atomically publish the object key. If no, it returns the list of bad/missing parts and stays open.
  5. Serve. Downloads go through a short-lived signed URL, CDN-fronted for shareable content, so app servers never proxy download bytes either (caching).
  6. Reclaim. A background sweep aborts sessions past expiresAt and garbage-collects their orphaned parts so abandoned uploads do not leak storage forever.

The first bottleneck this exposes is not bandwidth — that is the store's problem now — it is the commit step's correctness under retries, which is where the rest of the lesson lives.

6. Deep dives

6.1 Chunk size: a three-way tension

Part size is the one tuning knob that touches everything. It trades off three quantities at once:

Make parts SMALLER →Effect on resume granularityEffect on metadata/request overheadEffect on parallelism
e.g. 1 MBExcellent — a failure wastes ≤1 MB.Bad — 10,240 parts, 10k metadata rows + requests, more round trips.Good — many units to spread across connections.
e.g. 8 MBGood — failure wastes ≤8 MB (~6.4 parts expected, ~50 MB total).Moderate — 1,280 parts; comfortable metadata volume.Good — plenty of parallel units.
e.g. 256 MBPoor — one failure re-sends 256 MB; on a flaky link you may never finish a part.Excellent — only 40 parts, tiny metadata.Limited — few units; hard to saturate bandwidth.

The optimum is tied to the network failure rate. Expected wasted bandwidth from retries scales roughly as N · p · partSize, but since N = fileSize / partSize, that is fileSize · p for the re-send volume — note part size cancels there. What part size actually controls is the granularity of each loss (you re-send in whole-part units) versus the fixed overhead of more parts (each part costs a request, a signed URL, a metadata row, a round trip). So: on a lossy link, go smaller — you want each loss to be cheap and you can't afford a giant part to keep failing mid-flight. On a clean link, go larger — amortize the per-part overhead. 8 MB is a sane default precisely because at p≈0.5% the retry waste (~50 MB) is already negligible, so you bias toward fewer parts and lower overhead. (Note S3's hard limit of 10,000 parts also forces part size up for very large files: a 1 TB object needs parts ≥ ~100 MB.)

6.2 Direct-to-store upload via signed URLs

A signed URL is a capability: the control plane computes an HMAC over (object key, HTTP method, expiry, optional content constraints) using a secret the store also knows. The store validates the signature on arrival — no app-server involvement, no long-lived credentials handed to the client. This is what physically realizes the control/data-plane split: the app grants the right to write one specific part for the next few minutes, and the bytes flow on a path the app never sees.

The senior nuance is scoping. A part-upload URL should be scoped to exactly (bucket, key, partNumber), a single PUT, with a short expiry. If you over-scope (e.g. a wildcard write URL), a leaked URL becomes a write primitive over your bucket. The download side is symmetric: short-lived signed GET URLs, optionally with a CDN in front (caching) so the store isn't the egress bottleneck either. The failure-as-normal stance applies here too — if a URL expires mid-upload (the part took longer than the expiry on a slow link), the client just re-fetches a fresh URL from the control plane and retries the part. Resume already handles this.

6.3 Integrity — a 200 OK does not prove the right bytes landed

This is the subtlest part and the one juniors skip. A successful HTTP PUT means "the store accepted some bytes for this key." It does not mean those are the bytes the client intended: a truncated body that still got a 200, a buggy proxy that re-chunked, a client bug that read the wrong file offset, bit-rot between accept and durability — all return 200. Transport success is not data correctness.

So integrity is enforced with checksums at two levels, mirroring DDIA Ch. 4's "the encoding is the contract":

This double check protects against both client bugs (wrong bytes sent) and storage bugs (wrong bytes stored), and it is what makes the commit trustworthy.

6.4 Idempotent, atomic commit (the hard part)

Two retry hazards, both DDIA Ch. 11 idempotency patterns:

Idempotent part PUT. A client times out on part 47, doesn't know if it landed, and re-sends it. Because parts are keyed by (uploadId, partNumber), the re-send simply overwrites/coincides with the same logical slot — uploading part 47 twice is indistinguishable from uploading it once. No duplicate, no corruption. This is the chunk-dedup property: the address of a part is a deterministic function of its identity, not of when it arrived.

Atomic publish. complete is the linearization point. It must be all-or-nothing: validate the full manifest, and only then flip the object from completing to published in a single atomic step (a conditional metadata write / compare-and-set on session state). A second complete on an already-published session returns the same result — it is a no-op, not a re-publish. Until publish succeeds, no reader can resolve the object key; after it succeeds, all readers see the whole file. There is no window in which a download sees a half-assembled object.

7. Trade-offs

ChoiceBuysCostsChoose when
Proxy upload through app vs direct-to-storeCentral control: inline scanning, transforms, byte-level auth.App tier becomes a bandwidth bottleneck; loses stateless cheap scaling.Small files, or strict in-line scanning is mandatory.
Small chunks vs large chunksFine-grained, cheap retries; better parallelism on flaky links.More metadata rows, more requests, more round-trip overhead.Unstable networks / high per-part failure rate.
Serve through app vs signed URL + CDNPer-byte authorization control, audit on every read.Expensive app egress; no edge caching.Highly sensitive downloads needing per-request authz.
Scan before publish vs scan afterPre-publish scan = malware never served.Slower time-to-available; blocks the commit.Public sharing / untrusted uploaders → scan first.

The sharpest one is proxy vs direct. The reflex "route it through the app so we can scan and authorize every byte" feels safe and is almost always wrong at scale: it converts a stateless tier into a multi-gigabit pipe and makes your app the SPOF for every upload. The senior move is to keep the data plane direct-to-store and move the controls off the hot path — scan asynchronously after upload but before publish (the object is invisible until the scan and manifest both pass), and authorize at URL-issue time rather than per byte. You get the safety without making the app proxy 10 GB streams.

8. Failure modes

FailureMitigation
Client re-uploads a part it already sentParts keyed by (uploadId, partNumber) → idempotent; the repeat is a no-op (dedup).
complete called but a part never landedManifest validation rejects before assembly; session stays open, client re-sends only the missing part.
Part PUT returns 200 with wrong/truncated bytesPer-part checksum verified on write + final manifest checksum check; bad part rejected, not published.
Signed URL expires mid-upload (slow link)Client refetches a fresh URL and retries the part; resume state is unaffected.
User abandons uploadSession expiresAt sweep aborts and GCs orphaned parts so storage doesn't leak.
Duplicate complete callsAtomic CAS on session state → second call is a no-op returning the same published result.
CDN serves a revoked/replaced fileShort TTLs + explicit purge on revocation; version the object key so a new version is a new path.

9. Interview Q&A

  1. Client calls complete but part 47 never landed — how do you guarantee you don't publish a corrupt file? (senior answer) complete is the linearization point, not the part PUTs. It validates the full manifest first: every part number present and every stored per-part checksum matching the client's declared manifest (optionally a whole-file hash-of-hashes against the content hash from create). If part 47 is missing or its checksum is wrong, complete fails, the session stays open, and the client resumes by re-sending just part 47. Only when validation passes does the server atomically flip completing → published via a CAS; before that flip the object key resolves to nothing, so no reader ever sees a half-assembled file. And because the part PUT is idempotent on (uploadId, partNumber), the re-sent part 47 can't create a duplicate.
  2. How do you size the chunk? (senior answer) Tie it to the network failure rate. Smaller parts make each loss cheap and improve parallelism but inflate metadata/request overhead; larger parts amortize overhead but waste more per failure and can fail to finish on flaky links. On a clean link bias large; on a lossy one bias small. The re-send volume is roughly fileSize·p regardless of part size, so for typical p (~0.5%) the retry waste is already tiny and you pick part size to minimize overhead — 8 MB for a 10 GB file (1,280 parts, ~50 MB expected re-send). Watch the store's 10,000-part cap for huge files.
  3. Why not just proxy uploads through the app server so you can scan and authorize? (senior answer) Because that turns a stateless tier into a multi-gigabit proxy and the SPOF for every upload. Keep the data plane direct-to-store via signed URLs; move controls off the hot path — scan asynchronously after upload but before publish (object invisible until both scan and manifest pass), authorize at URL-issue time. Same safety, no byte proxying.
  4. A part PUT returned 200 — are you done? (senior answer) No. A 200 means the store accepted some bytes, not the right bytes — truncation, a re-chunking proxy, a client offset bug, or bit-rot all return 200. Transport success ≠ data correctness. You verify a per-part checksum on write and re-verify the whole set against the manifest at complete. Integrity is asserted by checksums at two levels, not inferred from HTTP status.
  5. Two requests both call complete concurrently — what happens? (senior answer) The publish is a single atomic compare-and-set on session state (completing → published). The first wins and publishes; the second sees the state already advanced and returns the same published result as a no-op. complete is idempotent — re-running it never re-assembles or double-publishes.
  6. How does resume actually work? (senior answer) On reconnect the client asks the control plane to list stored parts for the uploadId (part numbers + checksums). It diffs against its own part list and uploads only the missing ones. Because part addresses are deterministic (uploadId, partNumber), re-sending an ambiguous part is safe. The session's expiresAt bounds how long resume stays valid.
  7. What happens to abandoned uploads? (senior answer) Every session carries an expiresAt. A background sweep aborts expired sessions and garbage-collects their orphaned parts, so a user who closes the tab at 80% doesn't leak 8 GB indefinitely. This is a lifecycle policy on uncommitted parts, separate from the published-object lifecycle.
  8. How do you keep download bandwidth off your app servers? (senior answer) Symmetric to upload: hand out short-lived signed GET URLs, fronted by a CDN for public/shareable content (caching). The app authorizes at URL-issue time and never proxies download bytes. For revocation, use short TTLs plus explicit purge, and version object keys so a replacement is a new path.

Related lessons

This case anchors resumable multipart for the set: C14 (transcode) consumes a completed upload as its input and reuses the same chunk/idempotency machinery on the segment level; C15 (file sync) is the delta-aware sibling — same resumable upload, plus block-level diffing — so it references the protocol here rather than re-deriving it. The durable substrate the parts and manifest live on is C16 (object-storage durability). The whole stance — treat interruption as the normal case, retry the small unit safely — is foundation lesson 13 (fault tolerance) applied to a long-lived transfer, and the small, intentional surface follows 03 (API design). Edge serving leans on 06 (caching).

C14 transcode C15 file sync C16 object storage API design Fault tolerance Caching
Takeaway
A 10 GB upload over 20 Mbps takes ~68 minutes — long enough that interruption is guaranteed, so the design's job is to make a failure cost one part, not the file. Slice into ~1,280 8 MB parts (≈6.4 expected retries, ~50 MB re-sent vs 10 GB restarted), send them direct to the object store via signed URLs so the app never proxies bytes, and make every retryable operation safe: part PUTs idempotent on (uploadId, partNumber), and complete an atomic, manifest-validated, checksum-checked publish. A 200 OK proves transport, never correctness — integrity comes from per-part checksums plus a final manifest check. Get the commit right and resumability is free; get it wrong and resumability just lets you publish corruption faster.