System design interview
Design an image hosting service
Direct uploads, resized variants behind a CDN, EXIF stripping and deletes.
At a glance
- Foundation
- Files and media, Caching
- CDN · Image API · Image origin · Object storage · Metadata DB · Image worker · Upload events
Press Play to follow a request through the design, or step through it.
- Client and edge
- Service
- Data store
- Cache and queue
- The client says it wants to upload a 4 MB JPEG. The API checks the user's quota and the declared type and size.
- The API records a pending image with a new ID and a random storage key. It never uses the user's file name.
- It returns a signed URL, valid for ten minutes, that allows one upload to that key up to 20 MB.
- The client uploads the file straight to object storage using that URL.
- Storage publishes an event that an object was created. The queue holds it until a worker is free.
- An image worker takes the event.
- It reads the file, decodes it, strips metadata, makes the standard sizes and writes them back to storage.
- It records the content hash and the variants, and marks the image ready. Only now is the link served.
- CDN: caches variants
- Client
- Image API: signs, checks
- Image origin: id to blob, resizes
- Object storage: originals, sizes
- Metadata DB: owner, hash, status
- Image worker: checks, resizes
- Upload events
On this page
In 2 minutes
- The numbers. These are heavy bytes, not heavy requests. Keep the bytes off our own servers: uploads go straight to storage and views are served from a CDN.
- Upload path. Direct to object storage with a presigned URL.
- Image sizes. Common sizes ahead of time, the rest on demand from an allowlist.
- What we keep as the original. A re-encoded copy without metadata.
- Links. Immutable links cached for a year.
Read the 2-minute answerHide the 2-minute answer
The workload is heavy bytes, not heavy requests, so I keep the bytes off our servers. A client asks an image API for an upload slot. The API checks quota, records a pending image in a metadata database, and returns a presigned URL for one upload of up to 20 MB to a random key. The client sends the file straight to object storage, which emits an event to a queue.
An image worker checks magic bytes, decodes the file, limits pixel count, strips EXIF while applying rotation first, hashes the content to dedupe, makes three standard sizes and marks the image ready. Viewers get images from a CDN, with links that never change, so they cache for a year. On a miss, a thin image origin checks the metadata DB, returns 404 for deleted or pending images, finds the blob and reads or makes the variant. At 500 million views a day and a 95% hit ratio, the origin sees about 290 requests a second.
Rare sizes are made on demand, but only from an allowlist, with a strict cache key, so nobody can fill the cache. A delete marks the record, so the origin answers 404, purges the exact variant keys, deletes the image's own variants and removes the original when no record uses it. The trade-off is a two-step upload and a purge for every delete.
Step 1 of 6: Clarify the scope
Questions to ask first
Ask these before you draw anything. Guess why each answer changes the design, then open it to check.
What is the largest file, and which formats do we accept?
Is there a fixed list of sizes, or can a page ask for any size?
Are images public, or do some need access control?
Where are the viewers, and how many views per upload?
When someone deletes an image, how fast must it disappear?
Requirements
Functional
- Upload an image of up to 20 MB in JPEG, PNG or WebP format.
- Serve each image at a small set of sizes through a stable link.
- Let the owner delete an image, and stop serving it everywhere.
- Remove private metadata, such as GPS location, from every image we serve.
- Store identical files only once.
Non-functional
- A cached image arrives with p99 under 100 ms.
- At least 95% of views are answered by the CDN without touching our origin.
- No accepted upload is ever lost: every original is kept in at least 3 copies.
- A deleted image stops being served within 5 minutes.
Step 2 of 6: Size it
Show the mathsHide the maths
These numbers are assumptions for the exercise, not facts about a real system:
- 5 million images are uploaded a day, averaging 3 MB each.
- There are 500 million image views a day, and the average view sends 100 KB.
- Three standard variants are made for each image: a thumbnail of 15 KB, a medium size of 100 KB and a large size of 400 KB.
- The CDN answers 95% of views from its cache.
The arithmetic:
- Uploads per second
- 5M a day ÷ 86,400 s ≈ 58
- ≈ 58/s on average, about 290/s at a 5x peak
- Originals per day
- 5M × 3 MB = 15,000,000 MB
- 15 TB a day, about 5.5 PB a year (15 TB × 365)
- Variants per day
- (15 + 100 + 400) KB = 515 KB per image × 5M
- ≈ 2.6 TB a day, about 17% of the originals
- Views per second
- 500M a day ÷ 86,400 s ≈ 5,787
- ≈ 5,800/s on average
- Requests reaching the origin
- 5% misses × 5,800/s
- ≈ 290/s, easy for a small fleet
- Egress bandwidth
- 500M × 100 KB = 50 TB a day; × 8 bits ÷ 86,400 s
- ≈ 4.6 Gbit/s on average, mostly carried by the CDN
Step 3 of 6: Sketch the path of one request
Step through the diagram at the top of the page to watch a request move through these parts.
The Client first asks the Image API for an upload slot. The API checks the user's quota, records a pending image in the Metadata DB and returns a short-lived signed URL. The client then sends the file straight to Object storage, so 15 TB a day of image bytes never passes through our API servers.
When the object lands, storage emits an event to the Upload events queue. An Image worker picks it up. It checks that the file really is an image, strips private metadata, hashes the content, makes the standard sizes, writes them back to storage and marks the image ready in the metadata DB. Until then the image is not served.
Viewers load images from the CDN, which asks the Image origin only on a miss. Each link names an image and a size, and never changes, so the CDN can keep it for a year. The origin is a thin service. It looks the image up in the metadata DB, answers 404 for a deleted or pending image, finds the blob key, reads the variant from object storage, and makes an allowlisted size on demand if it does not exist yet. A delete goes through the API, which marks the image deleted and purges the CDN.
Other paths through the system
View an image
- A page asks for the medium size of image abc123 at its stable link.
- On a miss, the CDN asks the image origin for that one variant.
- The origin looks the image ID up in the metadata DB, to get its status and the key of its blob.
- The DB says it is ready and names the blob. A deleted or pending image gets a 404 here, and nothing is read.
- The origin reads the variant. If it is an allowlisted size not made yet, it resizes the original and stores the result.
- The origin returns the bytes. The CDN keeps a copy for a year because the link never changes.
- The client receives the image. Later viewers in the same region get it straight from the CDN.
Delete an image
- The owner asks to delete an image.
- The API marks the record deleted, so the origin answers 404 for it. A job deletes its variant files and, once no record shares it, the original.
- The API asks the CDN to purge the image's variants, whose keys are listed in the record, so cached copies stop being served.
Step 4 of 6: Go deep on the hard part
Direct uploads and file checks
How do uploads reach storage without loading your servers, and how do you stay safe from bad files?Sending 15 TB a day through API servers would tie up connections and bandwidth for no benefit.
Sending 15 TB a day through API servers would tie up connections and bandwidth for no benefit. Instead the client uploads directly to object storage with a presigned URL: a link the API signs, which allows exactly one action and expires soon.
The API fills the signature with rules. It names the exact storage key, which is a random ID and never the user's file name. It sets a time limit of about ten minutes. It caps the size, since object stores can enforce a content-length range in the signed policy. It also fixes the content type. Large files use multipart upload, so a dropped connection resumes from the last part. A rule on the bucket deletes pending objects that were never confirmed after a day.
Do not trust any of that for safety. The declared type and the file extension are claims from the client. The worker checks the real thing:
- Magic bytes. A JPEG begins with FF D8 FF, and a PNG with 89 50 4E 47. If the bytes do not match an allowed format, reject the file.
- A full decode. Open it with a well-maintained image library. A file that does not decode is not an image.
- A pixel limit. A small file can describe a huge picture. A PNG of 30,000 by 30,000 pixels is 900 million pixels, which needs about 3.6 GB of memory at 4 bytes a pixel. Read the dimensions from the header first and refuse anything above, say, 50 megapixels, which is about 200 MB decoded.
- A re-encode. Never serve the uploaded bytes as they are. Writing new pixels into a fresh file removes anything hidden inside, such as a script placed after the image data.
Serve images from a separate, cookie-free domain with a correct Content-Type and the header X-Content-Type-Options: nosniff, so a browser never treats an image as a page. Rate limit uploads per user, and keep the state pending until every check passes.
Variants: ahead of time or on demand
Should you make every size at upload time, or resize when someone asks? How do you stop abuse?There are two ways to get a 640-pixel-wide copy.
There are two ways to get a 640-pixel-wide copy.
- Ahead of time. The worker makes a fixed set, say 150, 640 and 1,600 pixels wide, when the image arrives. CPU use is steady and predictable, and the first viewer is never slow. The cost is wasted storage on sizes nobody opens, and a back-fill job whenever the set changes.
- On demand. The link carries the size, such as /abc123/w640.webp. On a CDN miss, the image origin makes it, returns it and the CDN keeps it. Nothing is wasted and any size is possible. The first viewer waits maybe 100 to 300 ms. The danger is abuse: an attacker can request widths 1, 2, 3 and up to 100,000, each a CDN miss and a CPU-heavy resize, and fill the cache with junk.
The defence for on-demand is a variant allowlist. Accept only sizes from a short list, such as 150, 320, 640, 1,024 and 1,600 pixels, and only the formats we choose, WebP and JPEG. Anything else gets a 400 before any work is done. That caps a single image at 5 sizes × 2 formats = 10 cached objects, instead of an unlimited number.
Next, control the cache key. It should be the image ID plus the variant name plus the format, and nothing else. Ignore unknown query parameters, or an attacker can add ?x=1, ?x=2 and so on to get a fresh miss each time. Collapse concurrent requests, so a thousand viewers who miss at once cause one resize, not a thousand. Cap the number of resizes running at once.
My pick is a hybrid. Make the three common sizes ahead of time, so almost every view is a plain storage read. Serve the rest of the allowlist on demand, by the image origin. Put a version or content hash in the link, so a link is immutable and can be cached for a year with the immutable directive.
Dedupe, metadata and deletes
How do you avoid storing the same file twice, protect people's privacy, and make a delete real?Dedupe. The worker computes a SHA-256 hash of the cleaned original.
Dedupe. The worker computes a SHA-256 hash of the cleaned original. If a blob with that hash already exists, it does not store a second copy. Each image record points to a shared blob of the original, and a reference count tracks how many records use it. The variants are not shared. Each record owns its own, made from the shared original, so deleting one image never touches another's sizes. The stable link names the image ID, so the origin must look the ID up in the metadata DB to find the blob. The bucket alone cannot answer that. Popular memes upload thousands of times, so this saves real space. It catches only exact copies. A resized or re-compressed version has a different hash. Do the check on the server only. If an upload could finish instantly when the file already exists, an observer could learn that someone else holds that file.
Metadata. Photos from a phone often carry an EXIF block with GPS coordinates, the camera's serial number and timestamps. Sharing a photo with an exact home location is a real harm. Strip all of it from everything we serve. One catch: the orientation tag. If you drop it without care, portrait photos turn sideways. So apply the rotation to the pixels first, then remove the tag. Convert colour profiles to sRGB so images look the same in every browser.
Deletes. A delete has more layers than it seems, so do them in order:
- Mark the record deleted in the metadata DB. This is the source of truth. Every CDN miss goes through the image origin, which reads the record and answers 404 for a deleted image, so a purged copy cannot come back.
- Purge the CDN. The record lists every variant key, at most about ten, so the purge is exact. Purges are not instant: allow seconds to a few minutes, which fits the 5-minute target.
- Delete the image's own variant objects at once, since only the original is shared. Lower the original blob's reference count. At zero, a cleanup job deletes it from storage.
- Backups age out at the end of their retention period.
Links are immutable and cached for a year, so a purge is the only way to remove a cached copy from the CDN. Be honest about the limit: a browser that already holds the image, or a person who saved it, keeps it. Say that plainly in the privacy policy.
Step 5 of 6: Name the trade-offs
- Direct to object storage with a presigned URL
- Through our API servers
Image bytes never load our servers, and uploads scale with the storage service. The cost is a two-step flow and an event-driven worker to process the file afterwards.
- Common sizes ahead of time, the rest on demand from an allowlist
- Every possible size on demand, or every size ahead of time
Most views are a plain read and rare sizes cost nothing until used. The cost is two code paths and care with the allowlist.
- A re-encoded copy without metadata
- The raw upload, untouched
Nothing private or hidden can ever leak out. The cost is that we cannot give back the exact original bytes.
- Immutable links cached for a year
- Mutable links with a short cache time
The CDN hit ratio stays very high and the origin stays small. The cost is that deletes and changes need a purge, and a new link for any edit.
Step 6 of 6: Say what breaks next
Follow-up questions an interviewer asks once the design is on the board. Answer each one out loud, then open it to compare.