API Design

Design Case Studies · Lesson 02

Design: File upload/download API

Naively routing file bytes through your API server is the single most expensive mistake in file handling — it saturates bandwidth, blocks request threads, and scales poorly. This case study shows how presigned URLs to object storage let you handle large, resumable uploads without your API touching a single byte of payload.

⏱ 20 min Difficulty: advanced Prereq: Case-study framework, Idempotency

By the end you'll be able to

Requirements

Functional requirements

Non-functional requirements

Scale assumption

10 000 active uploads per day, average file size 200 MB (so ~10 MB chunks, ~20 chunks per file). Peak concurrent uploads: ~500. Total daily data volume: ~2 TB. At this scale, a naive proxy architecture would require roughly 2 TB of bandwidth through the API fleet every day — which is the problem the presigned-URL design avoids entirely.

Design decisions

Presigned URLs to object storage — the core move

The central decision is: the API server never touches the file bytes. Instead, the API server exchanges metadata and issues short-lived signed URLs that let the client upload directly to object storage (e.g., S3, GCS, Azure Blob). The storage service checks the signature, accepts the bytes, and stores them. The API server's job is to orchestrate — create the upload record, generate the URLs, verify completion — not to proxy gigabytes of data.

This matters because object storage is built to ingest large byte streams at high concurrency; a general-purpose application server is not. A single 500 MB upload proxied through an API server ties up a connection and thread for potentially minutes. With presigned URLs, a 500 MB upload adds exactly two lightweight API calls (initialise and complete) regardless of file size; the heavy bytes travel on a separate path that scales independently.

Chunked / multipart upload

Splitting a 5 GB file into 10 MB chunks makes uploads resumable at a fine granularity. Each chunk gets its own presigned URL; the client uploads chunks in any order (or in parallel for speed) and tracks which have succeeded. On network failure, only the failed chunk needs to be retried — not the entire file. The storage service assembles the final file only after the client signals completion.

Idempotent chunk PUT

Each chunk PUT is addressed by its position (e.g., chunk number 4 of upload upl_abc). Re-uploading the same chunk to the same position is idempotent: the storage layer either acknowledges the existing chunk or overwrites it with the same bytes. The client never needs to know whether a chunk was received; it retries on any non-2xx response. See idempotency for the full treatment of why this matters for retries.

Checksums for integrity

The client computes an MD5 (or stronger: SHA-256) checksum of each chunk before uploading and includes it in the presigned URL or as a header. The storage service rejects chunks whose bytes do not match the declared checksum. The final "complete" call includes the overall file checksum; the API verifies the assembled file against it before marking the upload as ready. This catches bit-flip corruption and partial writes without requiring the API to inspect the bytes itself.

⚠️ Common trap

Designing the API to accept file bytes as a multipart form body on POST /v1/files. This is the textbook example of proxying bytes through the API tier. It works fine for files under ~10 MB, but once files reach hundreds of megabytes or gigabytes, this approach makes the API server the bottleneck. The shift from "upload to API" to "upload directly to storage via presigned URL" is the single most impactful design decision in any file handling system.

Rejected alternative: server-side streaming proxy

A streaming proxy (where the API streams bytes from the client to storage without buffering them all in memory) avoids the memory problem but not the connection-holding problem. During a slow upload, the API server holds a TCP connection open for minutes, preventing that connection from serving other requests. At 500 concurrent uploads, that is 500 connections tied up doing no compute work. Presigned URLs avoid this entirely.

The API model

===================================================================
 PHASE 1 — Initialise an upload
===================================================================
POST /v1/files
Authorization: Bearer <token>
Content-Type: application/json

{
  "filename": "quarterly-report.pdf",
  "content_type": "application/pdf",
  "size_bytes": 204857600,         // 200 MB — used to plan chunk count
  "sha256": "e3b0c44298fc1c149..."  // whole-file checksum declared upfront
}

→ 201 Created
{
  "upload_id": "upl_7g3kf",
  "status": "pending",
  "chunk_size_bytes": 10485760,   // 10 MB server-recommended chunk size
  "chunk_count": 20,
  "expires_at": "2026-06-21T00:00:00Z"  // incomplete uploads expire in 24 h
}

===================================================================
 PHASE 2a — Get a presigned URL for a chunk
===================================================================
GET /v1/files/upl_7g3kf/chunks/4/url
Authorization: Bearer <token>

→ 200 OK
{
  "presigned_url": "https://storage.example.com/uploads/upl_7g3kf/4?X-Amz-Signature=...",
  "url_expires_at": "2026-06-20T12:15:00Z",  // 15-minute URL lifetime
  "chunk_number": 4,
  "expected_md5": "7215ee9c7d9dc229d..."     // client verifies locally before PUT
}

===================================================================
 PHASE 2b — Client PUTs chunk directly to storage (no API involved)
===================================================================
PUT https://storage.example.com/uploads/upl_7g3kf/4?X-Amz-Signature=...
Content-Length: 10485760
Content-MD5: "7215ee9c7d9dc229d..."

<10 MB of binary data>

→ 200 OK  (from storage service, not from the API)
ETag: "7215ee9c7d9dc229d..."   // returned by storage for the complete call

===================================================================
 PHASE 3 — Signal upload complete
===================================================================
POST /v1/files/upl_7g3kf/complete
Authorization: Bearer <token>
Content-Type: application/json

{
  "parts": [
    { "chunk_number": 1, "etag": "a87ff679a2f3e71d9181..." },
    { "chunk_number": 2, "etag": "e4da3b7fbbce2345d77..." },
    /* … all 20 parts … */
  ]
}

→ 200 OK
{
  "upload_id": "upl_7g3kf",
  "file_id": "file_q9r2p",
  "status": "ready",
  "size_bytes": 204857600,
  "sha256_verified": true,
  "created_at": "2026-06-20T12:00:05Z"
}

===================================================================
 DOWNLOAD — Get a time-limited download URL
===================================================================
GET /v1/files/file_q9r2p/download-url?ttl_seconds=3600
Authorization: Bearer <token>

→ 200 OK
{
  "url": "https://storage.example.com/files/file_q9r2p?X-Amz-Signature=...",
  "expires_at": "2026-06-20T13:00:05Z"
}

===================================================================
 METADATA — Query file status
===================================================================
GET /v1/files/file_q9r2p
Authorization: Bearer <token>

→ 200 OK
{
  "id": "file_q9r2p",
  "filename": "quarterly-report.pdf",
  "content_type": "application/pdf",
  "size_bytes": 204857600,
  "status": "ready",    // pending | ready | failed | expired
  "sha256": "e3b0c44298fc1c149...",
  "created_at": "2026-06-20T12:00:05Z"
}
✅ Idempotent chunk uploads and resumability

When a client reconnects after a failure, it calls GET /v1/files/upl_7g3kf to retrieve the list of already-completed chunk ETags. It then requests presigned URLs only for the missing chunks and resumes. Because each chunk PUT is idempotent (same chunk number + same bytes = same ETag), even a chunk that was partially received can be safely retried in full — the storage service will accept it again and return the same ETag.

Client API Server Object Storage ① POST /v1/files (metadata) upload_id + chunk plan ② GET chunk URL presigned URL (15 min TTL) ③ PUT bytes directly — API server not involved 200 OK + ETag ④ POST complete (ETags) verify checksum file_id + status: ready zero bytes through API
Upload sequence. The green arrow is the only path where bytes flow — directly from the client to object storage. The API server handles only lightweight metadata calls.

Evaluation & latency budget

Why direct-to-storage offloads the API

Consider a 200 MB upload proxied through the API server vs. via presigned URL:

Metric Proxy through API Presigned URL
API server bandwidth per upload 200 MB in + 200 MB out ~2 KB in + ~2 KB out (metadata only)
API connection held open Duration of upload (~2 min for 200 MB at 15 Mbps) <100 ms per API call
API CPU during upload Streaming buffer management, TLS termination twice None (bytes go direct)
Scale ceiling Bounded by API fleet bandwidth Bounded by object storage (effectively unlimited)
Daily API bandwidth at 10k uploads ~4 TB ~40 MB

Resumability walkthrough

Suppose a client is uploading chunk 12 of 20 when the network drops. When it reconnects:

  1. Call GET /v1/files/upl_7g3kf. The response includes a completed_parts array listing chunks 1–11 with their ETags.
  2. Identify the gap: chunks 12–20 are missing.
  3. Request presigned URLs for chunks 12–20. Chunk 12 may have been partially received; the idempotent PUT will handle the retry safely.
  4. Upload chunks 12–20 directly to storage.
  5. Call POST /v1/files/upl_7g3kf/complete with all 20 ETags. The API assembles and verifies the file.

Total extra work on resume: only the missing chunks. For a 200 MB file with 10 MB chunks, a failure at chunk 12 means re-uploading at most ~90 MB, not the full 200 MB. For a 5 GB file, this is the difference between a failed upload and a tolerable retry.

🎯 Interview angle

When asked "how do you handle large file uploads?", the answer that separates senior engineers from juniors is: "I'd use presigned URLs so the API server never proxies the bytes." Then explain why: connection holding, bandwidth, scaling. Interviewers hear "chunked upload" as a follow-up; be ready to explain that chunked + idempotent + resumable are three separate properties that together give you a reliable large-file protocol.

Under the hood: the core mechanism

Presigned URLs look like magic — a URL the client uses to talk directly to storage without any credentials of its own. The mechanism is a conventional HMAC signature over a canonical string that encodes the intent: who authorized it, for which object, doing what, until when. Tamper with any component and the HMAC no longer matches.

How a presigned URL is constructed and verified

-- SERVER-SIDE: construct the presigned URL for chunk 4 of upload upl_7g3kf --

Step 1: build the canonical string-to-sign
string_to_sign =
  "PUT\n"                              // HTTP method
  + "7215ee9c7d9dc229d2921a40e899ec5f\n"  // Content-MD5 of this chunk (hex)
  + "application/octet-stream\n"         // Content-Type
  + "1750003600\n"                        // expiry as Unix timestamp (now + 900 s)
  + "/uploads/upl_7g3kf/4"               // object path (bucket + key)

Step 2: sign with HMAC-SHA256 using the storage service's secret key
raw_sig     = HMAC_SHA256(string_to_sign, secret_access_key)
signature   = base64url(raw_sig)
→ "SflKxwRJSMeKKF2QT4fwpMeJf36P..."

Step 3: assemble the presigned URL
presigned_url =
  "https://storage.example.com/uploads/upl_7g3kf/4"
  + "?X-Amz-Expires=900"
  + "&X-Amz-Date=20260620T120000Z"
  + "&X-Amz-Signature=SflKxwRJSMeKKF2QT4fwpMeJf36P..."

-- CLIENT-SIDE: PUT the chunk bytes to the presigned URL --

PUT https://storage.example.com/uploads/upl_7g3kf/4?X-Amz-Expires=900&X-Amz-Signature=...
Content-Length: 10485760
Content-MD5: 7215ee9c7d9dc229d2921a40e899ec5f
Content-Type: application/octet-stream

<10 MB binary data>

-- STORAGE-SIDE: verify the presigned URL --

1. Recompute string-to-sign from the incoming request (method + content-md5 + content-type + expiry + path)
2. Recompute HMAC-SHA256 with the stored secret key
3. Compare: constant_time_equals(recomputed_sig, X-Amz-Signature query param)
   → mismatch → 403 SignatureDoesNotMatch
4. Check expiry: now() > X-Amz-Date + X-Amz-Expires
   → expired  → 403 RequestExpired
5. Receive bytes; compute MD5 of received data; compare with Content-MD5 header
   → mismatch → 400 BadDigest (chunk corrupted in transit)
6. Store chunk; return 200 OK with ETag = MD5 of chunk (for the complete call)
   ETag: "7215ee9c7d9dc229d2921a40e899ec5f"

-- PHASE 3: multipart assembly (what POST /complete triggers) --

The client sends all 20 {chunk_number, etag} pairs to POST /v1/files/upl_7g3kf/complete.
Storage assembles the file:
  1. Sort parts by chunk_number
  2. Verify each part's stored ETag matches the submitted ETag
     → mismatch on part 7 → 422 (ETag in complete call doesn't match uploaded chunk)
  3. Concatenate all part byte-streams in order
  4. Compute final ETag = MD5( concat(etag_1, etag_2, ..., etag_20) ) + "-20"
     (the "-N" suffix is an S3 convention for multi-part ETags)
  5. Compute SHA-256 of assembled file; API compares with declared sha256 from Phase 1
     → mismatch → 422, status: failed
  6. Mark file as status: ready; issue file_id
Client PUT ① Recompute HMAC string-to-sign + secret ② Compare sig constant-time equals ③ Check expiry now() < expiry ts ④ Verify MD5 bytes vs Content-MD5 403 Signature DoesNotMatch tampered 200 OK ETag: <md5> all checks pass
Storage service verification pipeline for a presigned PUT. Each check gates the next; a tampered URL fails at step ② (HMAC mismatch) before any bytes are read. Only a PUT that passes all four checks receives a 200 and an ETag.
⚠️ URL expiry during slow uploads

A presigned URL with a 15-minute TTL issued to a client on a slow mobile connection (say, 1 Mbps) will expire before a 10 MB chunk finishes uploading: 10 MB / 1 Mbps ≈ 80 seconds — well within the TTL. But a client that queues several chunks and doesn't start uploading chunk 6 until 20 minutes after fetching its URL will receive a 403 RequestExpired. The fix is to request a fresh presigned URL for any chunk whose URL is within 60 seconds of expiry before beginning the PUT. The chunk's data and position in the upload are unaffected — only the authorization URL needs to be refreshed.

Operating & debugging it

Upload failures almost always fall into three buckets: a signature problem, an expiry problem, or a checksum mismatch. Each produces a distinct error code that points straight at the cause.

# Test a presigned URL directly with curl (bypasses the client app) $ curl -i -X PUT \ -H "Content-MD5: 7215ee9c7d9dc229d2921a40e899ec5f" \ -H "Content-Type: application/octet-stream" \ --data-binary @chunk_4.bin \ "https://storage.example.com/uploads/upl_7g3kf/4?X-Amz-Expires=900&X-Amz-Signature=..." HTTP/1.1 200 OK ETag: "7215ee9c7d9dc229d2921a40e899ec5f" # Save the ETag — you will need it for the complete call # Check which chunks have been received (to find resume point) $ curl -si -H "Authorization: Bearer <token>" \ https://api.example.com/v1/files/upl_7g3kf | python3 -m json.tool { "upload_id": "upl_7g3kf", "status": "pending", "completed_parts": [1,2,3,4,5,6,7,8,9,10,11], "missing_parts": [12,13,14,15,16,17,18,19,20] } # Upload dropped at chunk 12 — resume from here # Interpret a 403 error from storage $ curl -si ... | grep -A5 "Error" <Error><Code>SignatureDoesNotMatch</Code> <Message>The request signature we calculated does not match the signature you provided.</Message> # Cause: the URL was modified after signing, or the wrong secret key was used
SymptomLikely causeFix
PUT to presigned URL returns 403 SignatureDoesNotMatchURL was modified after signing (e.g., a proxy re-encoded query params), or a different secret key was used to sign vs. to verifyTest the URL verbatim from the API response with curl; check that no middleware is re-encoding the URL between API server and storage
PUT to presigned URL returns 403 RequestExpiredMore than X-Amz-Expires seconds elapsed between URL generation and the PUT — common on slow networks or queued uploadsRequest a fresh presigned URL for the chunk and retry; set URL TTL to at least 2× the expected upload time for that chunk size
POST /complete returns 422 with "checksum mismatch"The assembled file's SHA-256 does not match the value declared in Phase 1 — a chunk was corrupted, a wrong chunk was uploaded to a position, or the declared sha256 was wrongRe-download each chunk's ETag from the API and recompute the expected sha256; identify which chunk's MD5 is inconsistent
GET /files/:id still shows status "pending" 30 minutes after all chunks uploadedPOST /complete was never called, or it failed silently (the client didn't check the response)Check client logs for the complete call response; if it returned 4xx, the client should surface the error rather than silently polling status
Client retries chunk 7 but the final file is corruptedThe retry uploaded different bytes to chunk position 7 than the original (e.g., a bug where the file offset was recalculated incorrectly)On retry, re-read the chunk from the same byte offset as the original attempt; the chunk position is fixed, the bytes must match
Presigned URL works in curl but fails from the browserThe storage bucket does not have a CORS policy allowing PUT from the web app's originAdd a CORS rule to the storage bucket: AllowedOrigins: [https://app.example.com], AllowedMethods: [PUT], AllowedHeaders: [Content-MD5, Content-Type]

Debug checklist:

  1. Test the presigned URL directly with curl (no client app) — if curl succeeds and the browser fails, the issue is CORS on the storage bucket, not the signature.
  2. Check the error code in the storage response body (XML or JSON): SignatureDoesNotMatch, RequestExpired, and BadDigest each point to a different root cause.
  3. Call GET /v1/files/:upload_id to see the completed_parts list — confirm which chunks the API believes were successfully received before attempting to resume.
  4. For checksum failures at completion: recompute the SHA-256 of the original file locally and compare with what was sent in Phase 1; a mismatch there means the client computed the checksum incorrectly before declaring it.
  5. Verify that each chunk is read from the correct byte offset: chunk N starts at (N-1) × chunk_size_bytes and ends at min(N × chunk_size_bytes, file_size) — off-by-one errors here produce files that are byte-correct per chunk but in the wrong order or with gaps.

🧠 Quick check

1. The primary reason to use presigned URLs rather than proxying bytes through the API server is:

Security is a secondary benefit; the primary reason is scalability. When the API proxies bytes, each in-progress upload holds a connection for the duration — minutes for large files. With presigned URLs, the API call finishes in milliseconds and the connection is freed; bytes travel on a separate path to storage that scales independently.

2. Why is each chunk PUT idempotent?

Idempotency here comes from the combination of a stable address (chunk position) and stable content (same bytes → same ETag). The client can safely retry chunk 7 without knowing whether the original arrived — the result will be the same either way.

3. A client declares "sha256": "abc123" in the initialise call but uploads bytes that hash to a different value. When does the server detect this?

Individual chunk checksums detect per-chunk corruption; the whole-file SHA-256 declared at initialisation is verified only at the "complete" phase when all chunks are assembled. A mismatch returns a 422 error and marks the upload as failed.

4. An upload initialised 25 hours ago still has status "pending" with chunks 1–15 uploaded. What happens?

The non-functional requirement stated that incomplete uploads expire after 24 hours. A background cleanup job removes partial uploads to prevent permanent storage waste. Clients that attempt to continue an expired upload receive a 404 or a structured error indicating expiry.

✍️ Exercise: design the "list files" endpoint

A team member asks for an endpoint that lists all files belonging to the authenticated user, sorted by most recently uploaded, paginated. Files can have status pending, ready, failed, or expired. The UI wants to filter by status. Design the endpoint — path, params, response shape — and explain your pagination choice.

Model answer:

GET /v1/files?status=ready&cursor=eyJjcmVhdGVkIjoiMjAyN..."&limit=25
Authorization: Bearer <token>

→ 200 OK
{
  "data": [
    {
      "id": "file_q9r2p",
      "filename": "quarterly-report.pdf",
      "size_bytes": 204857600,
      "status": "ready",
      "created_at": "2026-06-20T12:00:05Z"
    }
  ],
  "next_cursor": "eyJjcmVhdGVkIjoiMjAy...",
  "total_estimate": 147
}

Rubric: ✓ cursor pagination (correct for potentially large lists; offset has O(n) cost) ✓ filter by status as a query param, not a path segment ✓ response includes only metadata, not a download URL (separate endpoint for that) ✓ sorted by created_at DESC (cursor encodes this) ✓ candidate explains why total_estimate is approximate (exact counts are expensive on large tables). All five = strong answer.

Key takeaways

Sources & further reading