- Media bytes should never stream through your application servers. The Upload service hands the client a short-lived pre-signed URL, and the client uploads directly to object storage (S3).
- The flow: request upload URL → pre-signed URL → direct upload → completion callback → metadata write →
PostCreatedevent. - Big files use multipart/chunked uploads; flaky mobile networks need resumable uploads (upload session id + byte offsets).
- Every post moves through a state machine (UPLOADING → PROCESSING → PUBLISHED / FAILED); retries are made safe with an idempotency key.
- Validate (size, real MIME type, malware/abuse), dedupe by content hash, clean up orphaned blobs, and publish events reliably with a transactional outbox.
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:
- Size and duration. A 60 MB video over a 5 Mbps uplink takes ~96 seconds. Mobile connections drop, switch from Wi-Fi to 4G, or the user locks the phone mid-upload.
- Retries create duplicates. If the response is lost after the server saved the post, the app retries — and naive code creates a second post.
- Scale and cost. Ingest is tens of gigabytes per second at peak. Routing those bytes through Python web servers would waste enormous compute.
- Trust. Uploaded files come from the internet: wrong extensions, gigantic dimensions, malware, illegal content.
- Multiple steps. Stored bytes alone are not a post: metadata, renditions, moderation, feed fan-out and search indexing must all follow, each able to fail independently.
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:
| Problem | Why it hurts |
|---|---|
| Worker occupancy | A 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 bandwidth | Every byte crosses the network twice: phone → app server, then app server → S3. You pay for and provision both. |
| Memory/disk pressure | Buffering large bodies risks OOM or fills local disk — and makes the service stateful. |
| Timeouts | Load balancers and gateways have request timeouts (often 30–60 s) and body-size limits; long uploads hit them. |
| Coupled scaling | Upload traffic spikes (New Year's Eve) force you to scale the API fleet, even though the work is pure I/O. |
| Blast radius | Slow 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
- The Upload service builds a canonical request: method (
PUT), object key, expiry, and selected headers (e.g.Content-Type: image/jpeg). - 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. - 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
- Short expiry (5–15 minutes; per-part URLs for multipart), so leaked URLs are useless quickly.
- Server-chosen object key — the client never picks the path, so it can't overwrite someone else's object.
- Signed headers:
Content-Typeand, where supported, a checksum header (x-amz-checksum-sha256) so S3 rejects bytes that don't match the declared hash. - Size limits: a pre-signed POST policy can include
content-length-range(e.g. 1 byte – 30 MB). With pre-signed PUT, signContent-Lengthor verify size at completion. - Staging location: uploads land in a
staging/prefix (or bucket) that nothing serves publicly until validated.
4. The end-to-end upload flow
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
- Request an upload. The app calls
POST /uploadswith an idempotency key, media type, byte size and (optionally) a SHA-256 of the file. The gateway authenticates the user and applies rate limits. - Create the upload session. The Upload service checks quotas and declared limits, generates a post ID (section 15), records a post row in state
UPLOADINGplus an upload session (expected size, parts, expiry, object key). - Return pre-signed URL(s) — one for a small photo, or one per part for multipart.
- Direct upload. The phone PUTs bytes to object storage; S3 returns an ETag per part.
- Completion callback. The app calls
POST /uploads/{id}/completewith 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.) - 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.
- Metadata write + event, atomically. In one local transaction: post →
PROCESSING, caption stored, and aMediaUploadedevent inserted into the outbox. Respond202 Accepted. - Relay publishes outbox rows to Kafka.
- Media processing creates renditions, thumbnails and video segments; moderation classifiers run.
- Publish. On
MediaReady(and passing checks) the post becomesPUBLISHEDandPostCreatedis emitted via the outbox, driving feed fan-out, search indexing and notifications.
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)
- S3 limits: parts are 5 MiB–5 GiB (except the last), up to 10,000 parts, objects up to 5 TiB. Other object stores have similar rules.
- Parts can be uploaded in parallel (2–4 concurrent streams on mobile) and in any order; a failed part is retried alone.
- Parts uploaded but never completed still cost storage until aborted (see section 12).
| Chunk size | Pros | Cons |
|---|---|---|
| Small (256 KB–1 MB) | Little re-sent after a failure; good on very poor networks | Many 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 minimum | A 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
- Upload session ID — created once, persisted on the device together with the file path and idempotency key, so it survives app restarts.
- Offset query — before resuming, the client asks "how many bytes do you have?" (the server's answer is authoritative; the client may have sent bytes that never arrived).
- Chunk integrity — each chunk can carry a checksum; the final object is verified against the full-file hash.
- Session expiry — sessions live hours to days; expired sessions are garbage-collected.
- Background transfer — iOS background
URLSessionand Android WorkManager let the OS continue uploads when the app isn't in the foreground.
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
- Resize and compress. Phone cameras produce 12–48 MP images (5–15 MB). Instagram displays photos at up to 1080 px wide, so the app resizes to that and re-encodes (JPEG/HEIC/WebP) to a few hundred KB–~1.5 MB — a 5–10× saving on upload time, ingest bandwidth and storage. Videos are transcoded on device to a target resolution/bitrate.
- Strip sensitive metadata. EXIF may contain GPS coordinates; strip it (on device and again on the server) unless the user explicitly adds a location.
- Compute the content hash for dedup and integrity.
- Start early. Instagram's early engineers publicly described a classic trick: begin uploading as soon as the user picks and filters the photo, while they're still typing the caption. By the time Share is tapped the bytes are often already there, so posting feels instant. If the user cancels, the orphaned upload is cleaned up later.
8. Validation and safety
| Check | Where | How |
|---|---|---|
| Size | Before signing + at completion | Reject declared sizes over limits; sign a content-length-range; verify actual size via HEAD on completion |
| Real MIME type | After upload, server-side | Never 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 / duration | During decode | Reject "decompression bombs" (tiny files declaring 100,000×100,000 px) and over-long videos; decode in a sandbox with memory/time limits |
| Malware | Async scan | Antivirus scanning of staging objects; isolate image libraries (historic exploits in image parsers); re-encoding neutralises most payloads |
| Illegal / abusive content | Async, before or shortly after publishing | Hash-matching against known-bad databases (e.g. PhotoDNA-style perceptual hashes) and ML classifiers; violations are blocked and reported |
| Quota / abuse | At URL request | Per-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..."
- Saves upload time, bandwidth and storage; the post metadata just points to an existing blob.
- Reference counting: a blob may be shared by many posts; delete it only when the last reference goes (and respect legal holds).
- Verify, don't trust: if the client claims a hash, the server must still verify the bytes it receives hash to that value, or an attacker could "claim" someone else's blob.
- Privacy side channel: global cross-user dedup can leak whether a file already exists ("upload is instant → someone has this file"). Mitigations: per-user dedup only, or always require a real upload for small files.
- Limits: exact hashes break with a single changed byte (a re-encode or crop). Near-duplicate detection uses perceptual hashes — useful for moderation and ranking, not storage dedup.
- Retries within one upload are better handled by the idempotency key (section 11) than by hashing.
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.
| State | Meaning | Who moves it on |
|---|---|---|
UPLOADING | Session created, bytes arriving; invisible | Upload service on verified completion → PROCESSING; sweeper after timeout → ABANDONED |
PROCESSING | Bytes stored; renditions and checks running; visible only to the author | Media/moderation results → PUBLISHED or FAILED |
PUBLISHED | Visible according to audience; PostCreated emitted | User delete or moderation → DELETED/REMOVED |
FAILED | Unrecoverable processing error or rejected content | Client 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
| Scenario | Without idempotency key | With it |
|---|---|---|
Response to POST /uploads lost, app retries | Two post rows, two sessions | Same post_id and session returned |
/complete times out, app retries | Two MediaUploaded events, double processing | Second call sees PROCESSING and returns the same result |
| User double-taps Share | Duplicate post in followers' feeds | Same 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.
| Mechanism | What it catches | Example |
|---|---|---|
| Lifecycle rule: abort incomplete multipart | Parts of uploads never completed (invisible in normal listings, but billed) | AbortIncompleteMultipartUpload: DaysAfterInitiation = 2 |
| Lifecycle expiry on staging prefix | Anything left in staging/ that was never promoted | Expire staging/* after 3 days |
| Session sweeper | Posts stuck in UPLOADING past the session expiry | Every 10 min: mark ABANDONED, delete their objects |
| Reconciliation job | Anything 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 purge | User-deleted posts | Mark 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 } } ] }
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)
- 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.
- Items upload in parallel (bounded concurrency, e.g. 2–3 at a time on mobile), each with its own resumable session.
/completesends the ordered list of media IDs plus caption; the post enters PROCESSING.- 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.statusvalues, done with a conditional update so exactly one worker flips it). - 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
- Size: a few minutes of 1080p video is tens to hundreds of MB even after on-device compression. Always multipart + resumable, with background transfer.
- Upload close to the user: large providers terminate uploads at a nearby edge PoP and forward over their private backbone (the same idea as S3 Transfer Acceleration), cutting round-trip time on each chunk.
- Process while uploading: Facebook's published Streaming Video Engine (SOSP 2017) splits video into segments as they arrive and transcodes them in parallel, so processing overlaps the upload instead of starting after it — dramatically reducing time-to-publish.
- Progressive availability: publish once a baseline rendition (e.g. 480p) and the first segments exist; higher-quality renditions for adaptive bitrate streaming are added later.
- Extra metadata: cover frame, audio track/music licensing, duration, aspect ratio, captions — all part of the PROCESSING step.
- Priority queues: short photo jobs shouldn't wait behind long transcodes; separate worker pools/queues per media type.
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
}
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
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.- Stories use the same path with a different post type and TTL — see Stories.
- DM media uses the same direct-upload idea, with access restricted to the thread's participants.
- Profile photos are small, but still go through validation and renditions.
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
- Letting the client choose the object key → overwriting other users' media.
- Long-lived upload URLs or logging them in full → anyone can upload into your bucket.
- Trusting
Content-Typeor file extension instead of decoding the file. - No idempotency key → duplicate posts on retries and double-taps.
- "Write DB then publish to Kafka" without an outbox → posts that never reach followers.
- Forgetting incomplete multipart uploads → invisible, billed storage growing forever.
- Publishing before processing finishes → followers see broken images.
20. Real-world technologies
| Technology | Use it for |
|---|---|
| Amazon S3 pre-signed URLs / POST policies, multipart upload | Direct uploads with expiry, size and content-type constraints; large objects |
| Google Cloud Storage signed URLs + resumable uploads; Azure Blob SAS tokens | Equivalent features on other clouds |
| tus protocol (tusd, Uppy) | Open, standard resumable upload protocol and clients |
| S3 Transfer Acceleration / CDN upload endpoints | Terminating 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 matching | Malware and known-abuse detection on staged uploads |
| libvips, ImageMagick, FFmpeg | Validation by decoding; resizing and transcoding |
| Debezium / outbox relays + Kafka | Reliable 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.
- Separate the control plane (auth, IDs, metadata, state) from the data plane (bytes): clients upload directly to object storage with short-lived, server-scoped pre-signed URLs.
- Use multipart for big files and resumable sessions with offsets for mobile networks; do client-side resize/compression and start uploading early.
- Model posts as a state machine (UPLOADING → PROCESSING → PUBLISHED/FAILED) with conditional transitions, and make every step idempotent via an idempotency key.
- Validate by decoding, quarantine in staging, scan for malware/abuse, and dedupe by content hash carefully.
- Plan for orphans (lifecycle rules, sweepers, reconciliation) and publish
PostCreatedvia a transactional outbox.
Related lessons
- Object Storage — where the bytes land; durability, lifecycle rules
- Media Processing — what happens after upload: renditions, transcoding, moderation
- Unique ID Generation — Instagram's 64-bit post IDs
- Microservices — sagas and the transactional outbox in depth
- API Design — idempotency keys and two-phase APIs
- Message Queues & Kafka — how PostCreated reaches consumers
- News Feed — the biggest consumer of PostCreated
- CDN — how the processed media is served back to viewers
- Security — signed URLs, malware scanning and abuse prevention