TL;DR

1. The problem: why is uploading media hard?

Posting a photo looks trivial: tap Share, done. Underneath, Instagram must accept a few megabytes (photo) to hundreds of megabytes (a long Reel) from a phone on an unreliable cellular network, store it durably forever, and do so for well over a hundred million media items a day without ever losing one or creating duplicates. Specifically:

🧠 Analogy Shipping a container of goods to a warehouse. You don't drive the truck through the head office's lobby. You phone the office, they issue a signed delivery permit valid for one bay and two hours (the pre-signed URL), and you drive straight to the warehouse (object storage). The guard checks the permit, not your identity. Once unloaded, you call the office with the receipt number (completion callback), and the office records it in the inventory ledger (metadata) and tells sales it's in stock (event). Big shipments come in several trucks (multipart); if a truck breaks down, only that truck's load is resent (resumable).

2. Why not just stream bytes through the app servers?

The naive design is POST /posts with a multipart body containing the photo; the web server receives it and writes it to S3. It works for a prototype and fails at scale:

ProblemWhy it hurts
Worker occupancyA synchronous Python worker (or thread) is tied up for the entire upload — 90 seconds for a slow video — doing nothing but copying bytes. 10,000 concurrent uploads = 10,000 busy workers.
Double bandwidthEvery byte crosses the network twice: phone → app server, then app server → S3. You pay for and provision both.
Memory/disk pressureBuffering large bodies risks OOM or fills local disk — and makes the service stateful.
TimeoutsLoad balancers and gateways have request timeouts (often 30–60 s) and body-size limits; long uploads hit them.
Coupled scalingUpload traffic spikes (New Year's Eve) force you to scale the API fleet, even though the work is pure I/O.
Blast radiusSlow uploads exhaust the same worker pool serving feed reads — uploads degrade everything.

Object stores like S3 are built to ingest enormous parallel byte streams from anywhere with high durability. The right design keeps the control plane (who may upload what, metadata, state) in your service and moves the data plane (the bytes) directly between client and storage.

3. Pre-signed URLs

3.1 What they are

A pre-signed URL is a URL to a specific object in a bucket that embeds an expiry time and a cryptographic signature made with the service's secret credentials. Anyone holding it may perform exactly the operation it was signed for (e.g. PUT this key) until it expires — without having any storage credentials themselves.

PUT https://media-uploads.s3.amazonaws.com/staging/7/3141592653589793238/orig
    ?X-Amz-Algorithm=AWS4-HMAC-SHA256
    &X-Amz-Credential=AKIA.../20261023/us-east-1/s3/aws4_request
    &X-Amz-Date=20261023T091500Z
    &X-Amz-Expires=900                       # valid for 15 minutes
    &X-Amz-SignedHeaders=content-type;host
    &X-Amz-Signature=5f2b9c...e81

3.2 How the signature works

  1. The Upload service builds a canonical request: method (PUT), object key, expiry, and selected headers (e.g. Content-Type: image/jpeg).
  2. It computes an HMAC-SHA256 over that string using a key derived from its secret access key, and appends the result as X-Amz-Signature.
  3. When the client uses the URL, S3 recomputes the HMAC with the same secret. If anything was changed — another key, method, content type, or a later time — the signatures don't match and S3 returns 403.

No call to S3 is needed to create the URL — it's pure local cryptography, so the Upload service can mint thousands per second cheaply.

3.3 Restricting what may be uploaded

⚠️ Upload URL ≠ download URL Pre-signed upload URLs are per-user secrets: never cache them on a CDN or log them in full. Media is later served through the CDN with separately signed, read-only URLs.

4. The end-to-end upload flow

📱 Mobile appcompress, hash LB + API gatewayauth, rate limit Upload servicesign URLs, state Post metadata DB outbox table Object storage (S3)staging/ → media/ Kafka Media workersresize, transcode ①⑤ API calls ②⑥ ④ bytes go direct (PUT) ⑧ relay ⑨ read / write renditions
Control plane (thin arrows) goes through the API; the data plane (thick blue arrow) goes straight from the phone to object storage.
 App                    Upload service              Metadata DB        S3 (staging)     Kafka
  |  (1) POST /uploads      |                            |                  |              |
  |  {idempotency_key,      |                            |                  |              |
  |   type, size, sha256}   |                            |                  |              |
  |-----------------------> | (2) auth, quota, validate  |                  |              |
  |                         |     post_id = new ID       |                  |              |
  |                         |---- INSERT post(UPLOADING) |                  |              |
  |                         |     + upload session ----->|                  |              |
  | <-- (3) upload_id,      |                            |                  |              |
  |     pre-signed URL(s)   |                            |                  |              |
  |                                                                         |              |
  |  (4) PUT bytes directly (multipart, resumable) ------------------------>|              |
  | <------------------------------------------------------- 200 + ETags --|              |
  |                                                                         |              |
  |  (5) POST /uploads/{id}/complete {parts, caption, ...}                  |              |
  |-----------------------> | (6) HEAD object: size, checksum ok? ------->|              |
  |                         | (7) one transaction:       |                  |              |
  |                         |   post -> PROCESSING       |                  |              |
  |                         |   outbox += MediaUploaded->|                  |              |
  | <-- 202 Accepted        |                            |                  |              |
  |     {post_id, PROCESSING}                            | (8) relay ------------------->  |
  |                                                      |                  |  (9) media workers,
  |                                                      |                  |  moderation consume
  |                         | (10) MediaReady -> post PUBLISHED + outbox PostCreated ----> |
  |                                                                       fan-out, search,
  |                                                                       notifications
  1. Request an upload. The app calls POST /uploads with an idempotency key, media type, byte size and (optionally) a SHA-256 of the file. The gateway authenticates the user and applies rate limits.
  2. Create the upload session. The Upload service checks quotas and declared limits, generates a post ID (section 15), records a post row in state UPLOADING plus an upload session (expected size, parts, expiry, object key).
  3. Return pre-signed URL(s) — one for a small photo, or one per part for multipart.
  4. Direct upload. The phone PUTs bytes to object storage; S3 returns an ETag per part.
  5. Completion callback. The app calls POST /uploads/{id}/complete with part ETags plus caption, tags, location and audience. (Alternatively, S3 event notifications can tell the service an object arrived; many designs use both — the client callback carries the metadata, the storage event acts as a safety net.)
  6. Verify. The service completes the multipart upload and checks the object really exists with the expected size and checksum. It never trusts the client's claim.
  7. Metadata write + event, atomically. In one local transaction: post → PROCESSING, caption stored, and a MediaUploaded event inserted into the outbox. Respond 202 Accepted.
  8. Relay publishes outbox rows to Kafka.
  9. Media processing creates renditions, thumbnails and video segments; moderation classifiers run.
  10. Publish. On MediaReady (and passing checks) the post becomes PUBLISHED and PostCreated is emitted via the outbox, driving feed fan-out, search indexing and notifications.
💡 What the user sees The app shows the post in the author's own profile immediately (optimistic UI) with a small progress indicator, while steps 8–10 take a second or two for photos and longer for videos. Followers only see it after PUBLISHED.

5. Multipart / chunked uploads

Sending a 200 MB video as one HTTP request is fragile: any blip restarts it from zero, and a single TCP stream rarely saturates a fast link. Multipart upload splits the file into parts uploaded independently (and in parallel), then stitched together server-side.

1. CreateMultipartUpload(key)            → upload_id
2. UploadPart(upload_id, part=1..N, bytes) → ETag per part   (each via its own pre-signed URL)
3. CompleteMultipartUpload(upload_id, [(1,ETag1), (2,ETag2), ...])  → final object
   (or AbortMultipartUpload to discard)
Chunk sizeProsCons
Small (256 KB–1 MB)Little re-sent after a failure; good on very poor networksMany requests and per-request overhead; below S3's 5 MiB multipart minimum, so needs a custom resumable layer
Medium (5–16 MB)Good balance for mobile video; meets S3 minimumA drop near the end of a part loses up to 16 MB
Large (64 MB+)Few requests; great on fast, stable links (desktop, server-to-server)Painful retries on mobile

Adaptive clients pick chunk size from measured throughput: bigger on Wi-Fi, smaller on weak cellular.

6. Resumable uploads

Multipart helps, but mobile needs more: if the app is killed or the network vanishes for 10 minutes, the upload must continue from where it stopped, not restart. A resumable protocol revolves around an upload session stored server-side:

 App                                   Upload endpoint (session store)
  | POST /uploads {size=48MB, sha256}  ->  session u_81 {received=0, expires=+24h}
  | PUT /uploads/u_81  Content-Range: bytes 0-8388607/50331648        -> 200 received=8MB
  | PUT /uploads/u_81  Content-Range: bytes 8388608-16777215/50331648 -> 200 received=16MB
  | PUT ... bytes 16777216-...   ✖ network lost (tunnel, app killed)
  |
  |  ... 10 minutes later, connectivity back ...
  | HEAD /uploads/u_81                     -> 200  Upload-Offset: 16777216
  | PUT /uploads/u_81  Content-Range: bytes 16777216-25165823/50331648 -> received=24MB
  | ...                                    -> received=48MB, complete ✔ checksum verified

Standard protocols: tus (open resumable upload protocol), Google Cloud Storage resumable uploads, and S3 multipart (resume by listing already-uploaded parts). Meta's public Graph API offers a resumable upload endpoint for Instagram video/Reels publishing, a sign that the same idea is used across its apps.

7. Client-side work before the upload

💡 Server still re-encodes Client compression is an optimisation, not a guarantee — clients can be old, buggy or malicious. The server-side media pipeline always decodes and re-encodes into its own standard renditions.

8. Validation and safety

CheckWhereHow
SizeBefore signing + at completionReject declared sizes over limits; sign a content-length-range; verify actual size via HEAD on completion
Real MIME typeAfter upload, server-sideNever trust extension or Content-Type. Read the file's magic bytes (JPEG FF D8 FF, PNG 89 50 4E 47, MP4 ftyp box) and fully decode it
Dimensions / durationDuring decodeReject "decompression bombs" (tiny files declaring 100,000×100,000 px) and over-long videos; decode in a sandbox with memory/time limits
MalwareAsync scanAntivirus scanning of staging objects; isolate image libraries (historic exploits in image parsers); re-encoding neutralises most payloads
Illegal / abusive contentAsync, before or shortly after publishingHash-matching against known-bad databases (e.g. PhotoDNA-style perceptual hashes) and ML classifiers; violations are blocked and reported
Quota / abuseAt URL requestPer-user rate limits, account-age and reputation checks against spam bots

Objects stay in a quarantined staging prefix that no CDN can read until validation passes; then they are promoted (copied or referenced) into the permanent media location.

9. Deduplication via content hash

Identical files are common: the same meme reposted, the same video re-uploaded after a failed attempt, an app retry that re-sends everything. Content-addressed storage uses a cryptographic hash (SHA-256) of the bytes as identity:

POST /uploads {sha256: "9f86d0...", size: 1843221, ...}
server: blob = blob_index.get("9f86d0...")
        if blob exists and blob.size == size:
            # skip the byte upload entirely
            create post referencing blob_id; refcount += 1
            return {upload_needed: false, post_id}
        else:
            return pre-signed URL for key "blobs/9f86d0..."

10. The post state machine

Because publishing spans several asynchronous steps, a post is modelled as an explicit state machine. Every component checks and advances the state; nothing else decides whether followers can see a post.

UPLOADING PROCESSING PUBLISHED DELETED FAILED ABANDONED complete +verified renditions ready+ checks pass user / mod transcode error,invalid media no completionwithin 24 h client retries
Post lifecycle. Only PUBLISHED posts are visible to followers; transitions are guarded so they can't go backwards or happen twice.
StateMeaningWho moves it on
UPLOADINGSession created, bytes arriving; invisibleUpload service on verified completion → PROCESSING; sweeper after timeout → ABANDONED
PROCESSINGBytes stored; renditions and checks running; visible only to the authorMedia/moderation results → PUBLISHED or FAILED
PUBLISHEDVisible according to audience; PostCreated emittedUser delete or moderation → DELETED/REMOVED
FAILEDUnrecoverable processing error or rejected contentClient may retry (re-upload)

Transitions are conditional updates (compare-and-set), so duplicate or out-of-order events can't corrupt state:

UPDATE posts SET status = 'PUBLISHED', published_at = now()
WHERE post_id = 42 AND status = 'PROCESSING';
-- 1 row updated → we won: insert PostCreated into outbox in the same transaction
-- 0 rows updated → already published (duplicate MediaReady) or deleted: do nothing

A watchdog also alerts on posts stuck in PROCESSING longer than expected, and re-drives them.

11. Idempotency on retries

Mobile clients retry constantly: timeouts, app restarts, flaky radios. The dangerous case: the server did create the post, but the response was lost. Without protection, the retry creates a duplicate post. The fix is an idempotency key: a UUID the client generates once per user action ("share this post") and sends with every retry of that action.

def create_upload(user_id, idem_key, req):
    existing = idem_store.get((user_id, idem_key))
    if existing:
        return existing.response                  # replay the original answer
    with db.transaction():
        # unique constraint on (user_id, idem_key) protects against two concurrent first attempts
        post_id = id_gen.next()
        db.insert_post(post_id, user_id, status="UPLOADING", idem_key=idem_key)
        resp = {"post_id": post_id, "upload_id": ..., "urls": sign_urls(post_id)}
        idem_store.put((user_id, idem_key), resp, ttl=24h)
    return resp
ScenarioWithout idempotency keyWith it
Response to POST /uploads lost, app retriesTwo post rows, two sessionsSame post_id and session returned
/complete times out, app retriesTwo MediaUploaded events, double processingSecond call sees PROCESSING and returns the same result
User double-taps ShareDuplicate post in followers' feedsSame key → one post
Part upload retried—Naturally idempotent: re-PUT of part N overwrites part N

Downstream consumers are idempotent too (dedupe on event ID, conditional state transitions), because Kafka delivery is at-least-once. See API design and resilience patterns.

12. Cleaning up orphaned blobs

Direct-to-storage uploads inevitably leave orphans: bytes in object storage with no published post pointing at them. Sources include users who cancel after the early upload started, apps killed mid-upload, multipart uploads never completed, posts that FAILED, and deleted posts. At hundreds of millions of uploads a day, even a 2% orphan rate is petabytes per year of paid-for garbage.

MechanismWhat it catchesExample
Lifecycle rule: abort incomplete multipartParts of uploads never completed (invisible in normal listings, but billed)AbortIncompleteMultipartUpload: DaysAfterInitiation = 2
Lifecycle expiry on staging prefixAnything left in staging/ that was never promotedExpire staging/* after 3 days
Session sweeperPosts stuck in UPLOADING past the session expiryEvery 10 min: mark ABANDONED, delete their objects
Reconciliation jobAnything the above missed (bugs, crashes between steps)Daily: diff the storage inventory against metadata; delete unreferenced blobs older than a grace period
Soft-delete then purgeUser-deleted postsMark deleted (hidden immediately); purge renditions after a recovery window, honouring legal holds; CDN purge
{ "Rules": [
  { "ID": "abort-stale-multipart", "Filter": { "Prefix": "" }, "Status": "Enabled",
    "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 2 } },
  { "ID": "expire-staging", "Filter": { "Prefix": "staging/" }, "Status": "Enabled",
    "Expiration": { "Days": 3 } } ] }
🚨 The race to avoid A garbage collector must never delete a blob that a just-committing post is about to reference. Always apply a grace period (only delete orphans older than, say, 48 hours, longer than any session can live), and prefer "mark, then sweep later" over immediate deletes. Deleting user content is irreversible; leaking a little storage for a day is cheap.

13. Carousels: multiple media per post

A carousel post contains several photos and/or videos (Instagram raised the limit from 10 to 20 items in 2024). Model it as one post with an ordered list of media:

posts       (post_id PK, author_id, caption, status, media_count, created_at, ...)
post_media  (post_id, position, media_id, media_type, width, height, status)  PK (post_id, position)
media       (media_id PK, blob_key, sha256, renditions{...}, duration_ms, status)
  1. The client requests one upload session per item (or a single batch call returning N pre-signed URL sets) — all under the post's idempotency key.
  2. Items upload in parallel (bounded concurrency, e.g. 2–3 at a time on mobile), each with its own resumable session.
  3. /complete sends the ordered list of media IDs plus caption; the post enters PROCESSING.
  4. Each item is processed independently; the post becomes PUBLISHED only when all items are ready (a per-post counter or a check of all post_media.status values, done with a conditional update so exactly one worker flips it).
  5. If one item fails, the post is not published half-complete; the author is asked to retry or remove that item.

Carousels multiply upload volume (average items per post > 1), and the feed must render the first item fast while lazily loading the rest — so the first item's renditions are prioritised in processing and prefetching.

14. Reels and large videos

15. Generating the post ID

The Upload service needs the post's ID at step 2 — before any bytes arrive — because the ID becomes part of the object key, the idempotency record, and later every event. Requirements: globally unique across all shards, generated without a central bottleneck, and roughly time-sortable so feeds and profile grids can sort by ID.

Instagram's publicly described scheme (2012 engineering blog) packs a 64-bit ID as:

 | 41 bits: milliseconds since custom epoch | 13 bits: logical shard ID | 10 bits: per-shard sequence |
   ~69 years of timestamps                    8,192 logical shards         1,024 IDs / ms / shard

It was generated inside each PostgreSQL logical shard by a PL/pgSQL function, so the ID also tells you which shard holds the row. See Unique ID generation for Snowflake, UUIDs and trade-offs.

16. Publishing PostCreated reliably

Everything downstream — follower feeds, search, notifications, analytics — hinges on the PostCreated event. If the post commits but the event is lost, followers never see it; if the event goes out but the commit fails, consumers process a ghost post. So the event is written with the transactional outbox pattern (explained fully in Microservices): the state change to PUBLISHED and the outbox insert happen in one local transaction, and a relay (poller or CDC) publishes outbox rows to Kafka.

{
  "event_id":   "evt-01HF7Q...",          // consumers dedupe on this
  "type":       "PostCreated",
  "post_id":    3141592653589793238,
  "author_id":  7,
  "created_at": "2026-10-23T09:16:02Z",
  "media":      [{"media_id": 991, "type": "photo"}, {"media_id": 992, "type": "video"}],
  "audience":   "followers",              // or close_friends, public...
  "caption_lang": "en",
  "schema_version": 3
}
Upload servicePUBLISHED + outbox
→relay
Kafkapost-events, key=author_id
→
Fan-out workerstimelines
+
Search indexer
+
Notifications"X posted"

Consumers must tolerate duplicates (at-least-once) and the event carries enough data (author, audience, media types) that most consumers needn't call back to the Upload service.

17. Estimating upload volume

Assumptions (not official figures): 500M users post feed media or stories on a given day; 150M feed/Reels media items (carousels included) and 400M story items per day; after client compression photos average 1 MB and videos 15 MB; 30% of items are video.

Items/day         = 150M + 400M                 = 550M
Average upload QPS = 550M / 86,400 s            ≈ 6,400 uploads/s   (peak ×3 ≈ 19,000/s)
Avg item size     = 0.7 × 1 MB + 0.3 × 15 MB    = 5.2 MB
Ingest/day        = 550M × 5.2 MB               ≈ 2.9 PB/day
Ingest bandwidth  = 2.9 PB / 86,400 s           ≈ 33 GB/s ≈ 265 Gbps average (~800 Gbps peak)
Control-plane API = ~2–3 calls per item         ≈ 15k–60k req/s peak — tiny vs the byte stream
Renditions        ≈ +50–100% storage on top of originals, then × replication/erasure coding

The control plane is a modest web service; the data plane is a firehose that only object storage and the network edge should touch. That asymmetry is the whole argument for pre-signed direct uploads. Redo this with your own numbers using Back-of-the-envelope estimation.

18. How Instagram uses the Upload service

📸 In Instagram The diagram's Upload Service (posts/reels, pre-signed URLs) is the control plane described above; Object Storage receives the bytes; the Post metadata store (Cassandra/sharded PostgreSQL by user) holds the post row and state; Kafka carries PostCreated to fan-out, search, notifications and analytics; Media Processing Workers write renditions back to object storage, which the CDN serves. Instagram's app has long started uploading before the user taps Share, resizes media on device, and uses resumable uploads for video (Meta's public Graph API exposes a resumable upload flow for Reels). In practice much of this logic lives inside Instagram's large Django web tier as an "upload domain", calling Meta's shared storage and video-processing infrastructure.

19. Trade-offs & common pitfalls

✅ Direct pre-signed uploads give you

  • App servers freed from byte shovelling; stateless, cheap control plane
  • Half the bandwidth; object storage scales ingest for you
  • Resumable, parallel transfers for mobile
  • Fine-grained, time-limited permissions without sharing credentials

⚠️ Costs

  • Multi-step protocol: more client logic and more states
  • Validation happens after bytes land, so staging + quarantine are needed
  • Orphaned objects need lifecycle rules and sweepers
  • Harder to enforce exact limits than with an inline proxy

Common pitfalls

20. Real-world technologies

TechnologyUse it for
Amazon S3 pre-signed URLs / POST policies, multipart uploadDirect uploads with expiry, size and content-type constraints; large objects
Google Cloud Storage signed URLs + resumable uploads; Azure Blob SAS tokensEquivalent features on other clouds
tus protocol (tusd, Uppy)Open, standard resumable upload protocol and clients
S3 Transfer Acceleration / CDN upload endpointsTerminating uploads near the user and using the provider's backbone
S3 Event Notifications → SQS/Lambda, S3 Inventory"Object arrived" safety net; reconciliation of orphans
ClamAV, cloud malware scanning; PhotoDNA-style hash matchingMalware and known-abuse detection on staged uploads
libvips, ImageMagick, FFmpegValidation by decoding; resizing and transcoding
Debezium / outbox relays + KafkaReliable PostCreated publication

21. Interview corner

Design the API for posting a photo on Instagram.

Two-phase API. POST /uploads with an idempotency key, media type, size and SHA-256 returns {post_id, upload_id, presigned_urls[], expires_at} and creates a post in UPLOADING. The client PUTs bytes directly to object storage (multipart/resumable). POST /uploads/{id}/complete with part ETags, caption, location, audience: the server verifies the object (size, checksum), moves the post to PROCESSING and writes a MediaUploaded event via the outbox, returning 202. Media workers create renditions; on success a conditional update sets PUBLISHED and emits PostCreated. The client polls or gets a push when published; the author sees it immediately via optimistic UI.

Why use pre-signed URLs instead of uploading through your API servers?

Uploads are slow, large and I/O-bound. Proxying them ties up app workers for the whole transfer, doubles bandwidth (client→app→S3), hits LB timeouts and body limits, and couples upload spikes to the API fleet. Pre-signed URLs keep authorisation in our service (short expiry, server-chosen key, signed content type/size) while object storage — designed for massive parallel ingest — receives the bytes directly. The API only handles tiny control requests.

A user uploads a 300 MB video on a train with patchy coverage. How do you make it succeed?

On-device transcode to a sane bitrate first. Create a resumable upload session (persisted on device with the file path and idempotency key). Upload in chunks (adaptive 1–16 MB) with per-chunk checksums using OS background transfer. On reconnect, query the server for the committed offset and continue from there. The session lives for e.g. 24 h; the final object is verified against the whole-file hash. Server-side, segments can be processed as they arrive (streaming processing) so publishing follows shortly after the last chunk.

The client's "complete" request timed out and it retried. How do you avoid a duplicate post?

Every request in the flow carries the idempotency key generated once per Share action; the server stores (user_id, key) → response with a unique constraint, so a retry replays the original result. The completion step is a conditional state transition (UPLOADING → PROCESSING only if still UPLOADING), so a second call sees PROCESSING and returns the same post. Downstream, the PUBLISHED transition is also conditional and consumers dedupe on event_id.

How do you make sure a newly published post reliably reaches followers' feeds?

The PUBLISHED transition and the PostCreated outbox insert are one local transaction. A relay publishes outbox rows to Kafka (at-least-once, keyed for ordering). Fan-out workers consume PostCreated and write the post ID into followers' Redis timelines (push), skipping celebrities whose posts are pulled at read time; ZADD is idempotent so redelivery is harmless. Consumer lag is monitored; if workers fall behind they catch up from the retained log.

How do you prevent storage from filling with abandoned uploads?

Land uploads in a staging prefix with a lifecycle expiry and an AbortIncompleteMultipartUpload rule; a sweeper marks posts stuck in UPLOADING past their session expiry as ABANDONED and deletes their objects; a daily reconciliation diffs storage inventory against metadata and deletes unreferenced objects older than a grace period; deleted posts are soft-deleted then purged after a recovery window. Grace periods prevent racing with in-flight commits.

One item in a 10-item carousel fails to transcode. What happens?

The post stays in PROCESSING until every item is ready and is never shown half-complete. The failed item is retried automatically (transient errors); if it fails permanently, the post goes to FAILED for that item and the author is prompted to retry the upload or remove the item, then the post is re-evaluated. Exactly one worker flips the post to PUBLISHED via a conditional update when the last item becomes ready.

22. Check your understanding

A 200 MB video is uploaded in 8 MB parts. How many parts, and how much is re-sent if the connection drops halfway through part 13?

200 / 8 = 25 parts. Parts 1–12 are already committed, so only part 13 (at most 8 MB) is re-sent — versus up to 200 MB without chunking.

Why must the server choose the object key rather than the client?

If the client chose the key, it could request a URL for another user's object path and overwrite their media. Server-chosen keys (derived from post/media IDs) plus a signature bound to that key make each URL usable only for its own object.

The app compresses photos to 1080 px before uploading. Why does the server still decode and re-encode them?

Client output can't be trusted: old app versions, bugs or malicious clients may send oversized, malformed or exploit-carrying files. Decoding validates the real format and dimensions, and re-encoding produces the platform's standard renditions and neutralises most embedded payloads.

Two MediaReady events for the same post arrive (at-least-once delivery). What prevents two PostCreated events?

The transition is a conditional update — UPDATE … SET status='PUBLISHED' WHERE post_id=? AND status='PROCESSING'. Only the first succeeds (1 row updated) and inserts PostCreated into the outbox in the same transaction; the second updates 0 rows and does nothing.

Using the volume assumptions above, roughly how many uploads per second must the system handle at a 3× peak, and why is the Upload service itself not the bottleneck?

550M/day ÷ 86,400 ≈ 6,400/s average, ≈ 19,000/s at peak. The Upload service handles only small control requests (2–3 per item), while the ~33 GB/s average byte stream goes directly to object storage via pre-signed URLs.

Why can global, cross-user dedup by content hash be a privacy problem, and what's a mitigation?

If uploading a file is "instant" when someone else already uploaded identical bytes, an attacker can probe whether a specific file exists on the platform. Mitigations: dedup only within one user's uploads, always require real bytes for small files, or verify possession by requiring the upload anyway and deduping only at the storage layer.

Scenario: a garbage collector deletes every blob in media/ without a matching post row. What can go wrong?

It can race with uploads whose bytes are already promoted but whose metadata transaction hasn't committed yet (or is being retried), permanently deleting a user's just-published media. Only delete orphans older than a safe grace period, and prefer mark-then-sweep.

🎯 Key takeaways

Related lessons