Design Instagram
Design Instagram. Start with one server and one database, let the numbers move bytes straight to storage, process media afterwards, serve views from a CDN and shard metadata by user, then stress it with crashing workers, a million likes, expiring stories and the storage bill, with interview prompts and code.
“Design Instagram.” Most candidates hear “news feed with pictures” and spend forty minutes on the feed. The interviewer usually wanted the pictures.
Instagram is three systems that share a login. A media pipeline takes a photo or video from a phone on a bad network, and turns it into versions for every screen. A serving layer sends those bytes from a CDN at terabytes a second, which is where the money goes. And a social layer, where one celebrity post can put a million likes on one row.
The feed itself is the news feed problem, so this page only sketches it. We start with one server and one database, and let the numbers break it. Then we test the finished design on what Instagram adds: bytes, counters and expiry.
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains each part.
- Bytes never touch our servers. The phone uploads straight to object storage in 8 MB parts, so a dropped connection costs one part. A CDN serves every view.
- Processing happens after the upload. Workers make each size of the photo or video, then mark it ready. Every step is safe to repeat, so crashes and duplicate events never lose or double a photo.
- Metadata is split by user, and every ID says which shard it lives on. A user’s profile and likes are one query on one shard, and any ID finds its shard without a lookup.
- Hot like counts are split into slots. The like itself is the truth. The count is a fast copy that can be a few seconds behind.
- Stories expire on read. Every read checks the time, so a story is never shown after 24 hours, and cleanup can happen in bulk later.
Scope it
Section titled “Scope it”Instagram is huge, so the first job is choosing what to design.
Design Instagram.
Whether you ask which features matter and how much is video, and say you will focus on media.
These four questions matter most:
- Which features? Upload photos and short video, the feed, likes, comments, and stories.
- How many users? 500 million a day. Some accounts have over 500 million followers, and some posts get millions of likes.
- How much video? A quarter of uploads, about 20 seconds each. Long video is a different problem.
- Do stories really disappear? Yes, from viewers after 24 hours. The author keeps a private archive.
It’s worth saying out loud: “I’d like to spend most of the time on uploads, storage and counters, and sketch the feed quickly. Does that match what you want?”
From these answers we make four promises:
- Viewing always works. 99.99% for the feed, profiles and media. Uploads may wait, because the app keeps unfinished uploads and retries them.
- Fast. Post and profile reads under 200 ms of server time. Images start arriving within 100 ms from the CDN.
- Quick to publish. A photo is live within 5 seconds of its upload finishing, a video within 60.
- Never lose a photo. Once an upload is accepted, it’s in two separate places before we say so.
We leave out direct messages, Reels recommendations, long video and ads.
Go deeperMore questions, and what each changes1 min
| Question | We assume | If the answer were different |
|---|---|---|
| Private accounts? | Yes, only approved followers see their media | All public lets media URLs be plain and cached anywhere |
| Only 5 million users? | No | One region, a commercial CDN and one Postgres with replicas |
| Photos only? | No, a quarter video | Removes video processing, the largest compute cost |
| Exact like counts? | No, a few seconds behind is fine | Exact counts per like need a much slower design |
The numbers that decide it
Section titled “The numbers that decide it”Now we work out the numbers. For Instagram, bytes matter more than requests.
- Daily active users
- 500 million
- Photos and videos viewed per user a day
- 200
- Peak over average
- 3×
- Feed photos and videos uploaded a day
- 100 million
- Story photos and videos uploaded a day
- 200 million
- Share of uploads that are video
- 25%
- Uploaded photo size
- 2 MB
- Uploaded video size
- 10 MB
- Bytes served per view, average
- 400 KB
- Likes per user a day
- 8
500 million daily users see about 200 photos and videos each a day.
So we serve media only from a CDN.
100 million posts and 200 million stories a day, a quarter of them video. At 2 MB per photo and 10 MB per video, the peak is 42 GB of uploads a second.
So we upload straight to object storage, never through our servers.
About 400 KB per view on average. That’s over 1,000 PB a month.
So we image sizes and the CDN hit rate are design decisions: this is the largest bill.
Each photo or video plus its smaller versions. 1.3 exabytes over five years, before copies.
So we three copies of photos nobody looks at is the cost to cut.
75 million videos a day, 20 seconds each, at about 8 core-seconds per second of video.
So we video gets its own queue and workers, so a video backlog never delays photos.
About one lookup per view. Likes alone are 4 billion a day, 140,000 a second at peak.
So we cache and shard from day one.
Try the calculator. Even at a tenth of the uploads and no video, uploads are 2.1 GB a second. So direct uploads aren’t an optimization here. They’re the only sane path.
- Media views, average
- 1,200,000/s
- Media views, peak
- 3,500,000/s
- Every view is an object fetch: the CDN takes them, not our servers.
- Uploads, average
- 3,500/s
- Uploads, peak
- 10,000/s
- Upload ingest, peak
- 42 GB/s
- More than a fleet of API servers should relay: clients upload straight to object storage with presigned URLs.
- CDN egress, peak
- 1.4 TB/s
- Egress is the largest bill on the page: a CDN with PoPs inside ISPs, small renditions, and hit ratio as a first-class metric.
- Origin fetches at a 95% hit ratio, peak
- 170,000/s
- Add an origin shield tier so every PoP does not miss to storage separately.
- Feed media stored a day
- 700 TB
- Original plus renditions: about 1.3× for photos, 2× for video.
- Feed media over 5 years, one copy
- 1300 PB
- Past 100 PB, keeping three full copies of cold media is the cost to cut: move it to an erasure-coded warm tier.
- Live stories, 24 hours of renditions
- 1.4 PB
- A rolling window, not growth: renditions are deleted when the story expires.
- Transcoding cores, average
- 140,000
- Video is the largest compute line: its own queue apart from photos, and full ladders only for videos people watch.
- Likes, peak
- 140,000/s
- Spread over millions of posts this is easy. One celebrity post is the hard part.
Build the design
Section titled “Build the design”We start with the simplest design that works. Then we let the numbers break it, one at a time. Each step fixes only what broke, adds only the boxes that fix needs, and says what the fix costs. Throughout, follow one scenario: a user on the subway uploads a 50 MB video and loses signal halfway, and then a million people want to see it.
Step 1. One server, one database
Section titled “Step 1. One server, one database”The simplest Instagram is one app server and one database. The phone sends the photo to the server, the server saves it to disk and writes a row, and the server sends the photo back to every viewer.
Stage 1: One server, one database
Stage 1 of 9
One server, one database
Why this stage
Nothing yet.
This is the smallest design that lets a user post a photo and others see it.
What we add
An app server that receives the upload, resizes it, writes the files to its own disk and serves them back, and one Postgres with users, posts and likes.
What it costs
Every byte of every upload and every view passes through one machine, and every like is a write on one database.
At 3.5M media views per second, API servers is 70000% busy. Stage 2 is the fix.
The numbers behind the busy bars
How busy it gets
- For every 300 media views, someone uploads 1 photo or video.
- There is about one API request for every 10 views, and one feed load for every 20.
- Each view needs about one metadata lookup, for the post around it.
What one machine can do
- One app server serves about 5,000 small files a second and 500 uploads when it handles the bytes itself. Without the bytes, it handles about 10,000 metadata requests.
- One Postgres shard serves about 10,000 reads a second and 8,000 writes. There are 64 shards.
- One 64-core media worker handles about 1.6 uploads a second, because video dominates: 20 seconds of video at 8 core-seconds per second.
- The feed service handles about 4,000 loads a second per server.
How big the caches are
- The metadata cache is 30 Redis nodes, about 200,000 lookups a second each. It answers 95% of lookups.
- The counter store is 20 Redis nodes.
Why writes add up
- Each upload brings about 28 metadata writes: the post, comments, follows and about 13 likes.
- Until stage 7, each like is an edge plus a counter update.
The numbers break this at once, in both directions. Uploads arrive at 42 GB a second at peak, and views leave at 1.4 TB a second. No fleet of app servers should carry those bytes. And a phone on the subway that loses signal halfway would start a 50 MB upload again from zero.
Step 2. Bytes go straight to storage
Section titled “Step 2. Bytes go straight to storage”The phone asks our API to start an upload. The API creates a media row and hands back presigned URLs: links that let the phone write directly to Amazon S3, for a limited time, without our servers in between.
POST /v1/uploads{ "uploadKey": "c1f0e2a4-...", "contentType": "video/mp4", "bytes": 48211904 }
201 Created{ "mediaId": "2948102844431360001", "partSize": 8388608, "parts": [{ "n": 1, "url": "https://upload.example/m/2948.../1?X-Sig=..." }, ...] }The phone uploads 8 MB parts, several at once. After losing signal, it asks which parts arrived,
and sends only the rest. The app creates uploadKey itself, so a retried request gets the same
mediaId back. The app even starts uploading the moment the user picks a photo, while they type a
caption, so most uploads finish before they press Share.
Stage 2: Bytes go straight to storage
Stage 2 of 9
Bytes go straight to storage
Why this stage
At the peak, 42 GB/s of uploads.
No fleet of API servers should relay that many bytes, and a phone that loses signal should not restart a 50 MB upload from zero.
What we add
Object storage for originals and renditions, and presigned URLs so phones upload straight to it, in parts.
API servers handle only metadata.
What it costs
The bytes and the post row live in two systems, so an upload must be tied to its post when it completes.
At 3.5M media views per second, Metadata DB is 35000% busy. Stage 3 is the fix.
The numbers behind the busy bars
How busy it gets
- For every 300 media views, someone uploads 1 photo or video.
- There is about one API request for every 10 views, and one feed load for every 20.
- Each view needs about one metadata lookup, for the post around it.
What one machine can do
- One app server serves about 5,000 small files a second and 500 uploads when it handles the bytes itself. Without the bytes, it handles about 10,000 metadata requests.
- One Postgres shard serves about 10,000 reads a second and 8,000 writes. There are 64 shards.
- One 64-core media worker handles about 1.6 uploads a second, because video dominates: 20 seconds of video at 8 core-seconds per second.
- The feed service handles about 4,000 loads a second per server.
How big the caches are
- The metadata cache is 30 Redis nodes, about 200,000 lookups a second each. It answers 95% of lookups.
- The counter store is 20 Redis nodes.
Why writes add up
- Each upload brings about 28 metadata writes: the post, comments, follows and about 13 likes.
- Until stage 7, each like is an edge plus a counter update.
Step 3. Processing comes after
Section titled “Step 3. Processing comes after”With bytes going straight to storage, there’s no server in the middle to resize the photo. So the upload ends with an event. Storage announces “new object”, a media queue holds the event, and media workers check the file, make each size, and mark the media row ready. Photos get four sizes. Videos get 480p and 720p first, and 1080p a few minutes later.
- Color key
- Database
- Background worker
- Queue or stream
- Service
The user can share the post while the media is still processing. It waits as pending, and goes
live when its last photo is ready. Meanwhile the author’s app shows it from the local file, so it
feels instant. A photo takes about a second, a 20-second video about 15 seconds to be playable.
Stage 3: Processing comes after
Stage 3 of 9
Processing comes after
Why this stage
A 12 MP original is the wrong file to show on a phone, and resizing a photo or transcoding a video takes seconds to minutes, far too long for the upload request.
What we add
A media queue fed by storage events, and workers that resize photos and transcode video into renditions.
What it costs
A post appears a few seconds after the upload finishes, and every step must be safe to retry.
At 3.5M media views per second, Metadata DB is 35000% busy. Stage 4 is the fix.
The numbers behind the busy bars
How busy it gets
- For every 300 media views, someone uploads 1 photo or video.
- There is about one API request for every 10 views, and one feed load for every 20.
- Each view needs about one metadata lookup, for the post around it.
What one machine can do
- One app server serves about 5,000 small files a second and 500 uploads when it handles the bytes itself. Without the bytes, it handles about 10,000 metadata requests.
- One Postgres shard serves about 10,000 reads a second and 8,000 writes. There are 64 shards.
- One 64-core media worker handles about 1.6 uploads a second, because video dominates: 20 seconds of video at 8 core-seconds per second.
- The feed service handles about 4,000 loads a second per server.
How big the caches are
- The metadata cache is 30 Redis nodes, about 200,000 lookups a second each. It answers 95% of lookups.
- The counter store is 20 Redis nodes.
Why writes add up
- Each upload brings about 28 metadata writes: the post, comments, follows and about 13 likes.
- Until stage 7, each like is an edge plus a counter update.
Follow an upload through the design:
Trace a request: Start an upload
Start an upload: step 1 of 7
Users to Load balancer
POST /v1/uploads with the content type, size and a client-generated upload key. The phone has already resized the photo to 1080 px and stripped location metadata if the user asked.
- Users → Load balancer30 ms
- Load balancer → API servers1 ms
- API servers → Metadata DB5 ms
- Users → Object storageasync
- Object storage → Media queueasync
- Media queue → Media workersasync
- Response
Go deeperWhere the seconds go after an upload1 min
| Step | Photo | 20 s video |
|---|---|---|
| Waiting in the queue at peak | Under 1 s | Under 5 s (its own queue) |
| Check the real file type, size and format | 50 ms | 200 ms |
| Safety checks (known-abuse matches, a classifier) | 100 ms | 1 s, on sampled frames |
| First sizes | 300 ms, all four | 10 s for 480p and 720p, pieces in parallel |
| Mark ready, publish the pending post | 10 ms | 10 ms; 1080p follows within minutes |
| Total | About 1 s | About 15 s to playable |
Video is cut into short pieces that are processed in parallel, which is why a 20-second video is playable in 15 seconds instead of a minute.
Step 4. Views come from a CDN
Section titled “Step 4. Views come from a CDN”Bytes no longer pass through our servers. But every view still reads object storage, 3.5 million
times a second at peak. So every image and video is served by a CDN, which reads object storage only
on a miss. Media URLs name one exact version, like https://cdn.example/m/{mediaId}/v3/640.webp.
That file never changes, so the CDN and the phone can cache it for a year.
Stage 4: Views come from a CDN
Stage 4 of 9
Views come from a CDN
Why this stage
At the peak, 3.5 million media views a second.
One server serves about 5,000 files a second.
What we add
A CDN in front of object storage.
Media files never change, so edges and phones cache them for a year.
What it costs
A CDN bill that grows with every view, and new media that misses once at every edge.
At 3.5M media views per second, Metadata DB is 35000% busy. Stage 5 is the fix.
The numbers behind the busy bars
How busy it gets
- For every 300 media views, someone uploads 1 photo or video.
- There is about one API request for every 10 views, and one feed load for every 20.
- Each view needs about one metadata lookup, for the post around it.
What one machine can do
- One app server serves about 5,000 small files a second and 500 uploads when it handles the bytes itself. Without the bytes, it handles about 10,000 metadata requests.
- One Postgres shard serves about 10,000 reads a second and 8,000 writes. There are 64 shards.
- One 64-core media worker handles about 1.6 uploads a second, because video dominates: 20 seconds of video at 8 core-seconds per second.
- The feed service handles about 4,000 loads a second per server.
How big the caches are
- The metadata cache is 30 Redis nodes, about 200,000 lookups a second each. It answers 95% of lookups.
- The counter store is 20 Redis nodes.
Why writes add up
- Each upload brings about 28 metadata writes: the post, comments, follows and about 13 likes.
- Until stage 7, each like is an edge plus a counter update.
Step 5. Shard the metadata by user, with the shard inside the ID
Section titled “Step 5. Shard the metadata by user, with the shard inside the ID”Metadata is the small stuff: posts, captions, likes, comments. A cache answers about 95% of lookups. Behind it, Postgres is split into 64 shards, because 290,000 writes a second is far more than one database takes. The real decision is what to split by.
| Split by | Post by ID | Profile grid | “Did I like these?” | Problem |
|---|---|---|---|---|
| Hash of the post ID | One shard | Every shard | Every shard | Every profile view asks every shard |
| User ID, plus a directory for IDs | Directory, then one | One shard | One shard | A directory lookup on the busiest path |
| User ID, with the shard inside the ID | One shard | One shard | One shard | A user can never change shards |
Instagram described this in 2012. Each ID is 64 bits: 41 bits of time, 13 bits naming the owner’s logical shard, and 10 bits of counter. So any ID tells you its shard directly, with no lookup. There are 8,192 logical shards spread over 64 servers. Growing means moving whole logical shards to new servers, never reshuffling users. And IDs still sort by time.
Likes are stored with the person who liked. So “did I like these 20 posts?” is one query on one shard. And a celebrity’s likes spread across every shard, instead of piling onto hers. A separate table of who liked each post is filled in the background.
Stage 5: Shard the metadata
Stage 5 of 9
Shard the metadata
Why this stage
Each view needs about one metadata lookup, 3.5 million a second at peak, and one Postgres serves about 10,000.
Writes are near 290,000 a second.
What we add
A metadata cache in front, and Postgres split into 64 shards by user ID.
Every post and media ID carries its owner’s shard number, so a lookup by ID goes straight to the right shard.
What it costs
A user lives on one shard for good, and anything not keyed by user (likers of a post) needs its own table.
At 3.5M media views per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
How busy it gets
- For every 300 media views, someone uploads 1 photo or video.
- There is about one API request for every 10 views, and one feed load for every 20.
- Each view needs about one metadata lookup, for the post around it.
What one machine can do
- One app server serves about 5,000 small files a second and 500 uploads when it handles the bytes itself. Without the bytes, it handles about 10,000 metadata requests.
- One Postgres shard serves about 10,000 reads a second and 8,000 writes. There are 64 shards.
- One 64-core media worker handles about 1.6 uploads a second, because video dominates: 20 seconds of video at 8 core-seconds per second.
- The feed service handles about 4,000 loads a second per server.
How big the caches are
- The metadata cache is 30 Redis nodes, about 200,000 lookups a second each. It answers 95% of lookups.
- The counter store is 20 Redis nodes.
Why writes add up
- Each upload brings about 28 metadata writes: the post, comments, follows and about 13 likes.
- Until stage 7, each like is an edge plus a counter update.
This is enough for a mid-level answer. Direct uploads, processing after, a CDN, and metadata sharded by user. The stress tests are what make it a senior answer.
Go deeperMoving a logical shard to a new server1 min
With 8,192 logical shards on 64 servers, each server holds 128 of them. Each logical shard is its own Postgres schema, so it can be copied as one unit. Growing to 128 servers means moving half of each server’s logical shards to a new machine.
A move has four steps. First, the new server starts as a live copy of the old one, using the database’s own replication. Second, once it has caught up, writes to the moving shards pause for a second or two. Third, the small map of “logical shard to server” is updated, and every API server picks up the change. Fourth, writes resume on the new server, and the old copies are dropped a day later.
No ID changes, and no user moves between logical shards, because the shard number is inside every ID. That is the payoff of putting it there.
Go deeperThe feed and the follow graph, in brief2 min
The feed is the news feed design, unchanged. In short, a
worker copies each new post ID into every follower’s timeline, a short list in Redis. Accounts with
more than about a million followers are skipped, and their posts are merged in when the feed is
read. The one change: copying starts only when the post leaves pending, so nobody sees a post
whose photo is still processing.
The follow graph is two tables: who follows me, on my shard, and who I follow, on yours. Both are one-hop questions, which plain sharded SQL answers well.
Stage 6: Follows and the feed
Stage 6 of 9
Follows and the feed
Why this stage
The product requirement: a home feed of posts from accounts you follow.
Not the slider, which stays green.
What we add
A graph store holding follow edges in both directions, and a feed service with hybrid fan-out, the design from the news feed page.
What it costs
A follow writes two rows on two shards without a transaction, so a repair job reconciles them.
At 3.5M media views per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
How busy it gets
- For every 300 media views, someone uploads 1 photo or video.
- There is about one API request for every 10 views, and one feed load for every 20.
- Each view needs about one metadata lookup, for the post around it.
What one machine can do
- One app server serves about 5,000 small files a second and 500 uploads when it handles the bytes itself. Without the bytes, it handles about 10,000 metadata requests.
- One Postgres shard serves about 10,000 reads a second and 8,000 writes. There are 64 shards.
- One 64-core media worker handles about 1.6 uploads a second, because video dominates: 20 seconds of video at 8 core-seconds per second.
- The feed service handles about 4,000 loads a second per server.
How big the caches are
- The metadata cache is 30 Redis nodes, about 200,000 lookups a second each. It answers 95% of lookups.
- The counter store is 20 Redis nodes.
Why writes add up
- Each upload brings about 28 metadata writes: the post, comments, follows and about 13 likes.
- Until stage 7, each like is an edge plus a counter update.
| Option | Fits because | Costs |
|---|---|---|
| Sharded SQL, both directions | One-hop scans; exact counts; no duplicate edges | Two writes per follow, on two shards |
| A wide-column store | Huge follower lists page well | Weaker uniqueness and counts |
| A graph database | Questions many hops deep | Instagram asks one-hop questions over billions of edges |
A follow is two writes on two shards with no shared transaction, so a repair job fixes any pair that got half-written. A list of 600 million followers is about 12 GB on one shard, and the feed never reads it, because such accounts are merged in at read time instead. “Friends of friends” questions are answered by an offline job.
Go deeperThe rest of the API, and where things are stored2 min
POST /v1/uploads/{mediaId}/complete finish the upload; row moves to "uploaded"POST /v1/posts Idempotency-Key, { caption, mediaIds } → 201 pendingPUT /v1/posts/{postId}/likes/me 204; liking twice is liking onceDELETE /v1/posts/{postId}/likes/me 204POST /v1/posts/{postId}/comments Idempotency-Key, { text } → 201POST /v1/stories { mediaId } → 201 { storyId, expiresAt }GET /v1/stories/tray people you follow with a live storycomplete is done by our API, not the phone. It finishes the upload with storage and moves the row
from uploading to uploaded with a conditional update, so a retried complete finds it already
moved and gets the same answer.
media (media_id BIGINT PRIMARY KEY, owner_id BIGINT, kind TEXT, state TEXT, -- uploading | uploaded | processing | ready | failed upload_key UUID UNIQUE, -- a retried create finds the same row renditions JSONB, width INT, height INT, lease_owner TEXT, lease_until TIMESTAMPTZ, attempts INT, version BIGINT)likes (user_id BIGINT, post_id BIGINT, PRIMARY KEY (user_id, post_id)) -- on the liker's shardcomments (post_id BIGINT, comment_id BIGINT, author_id BIGINT, text TEXT, PRIMARY KEY (post_id, comment_id)) -- on the owner's shardPhotos never go in the database. A row holds storage keys, and what the app needs to lay out a post before the bytes arrive: size, length, which versions exist.
Stress it
Section titled “Stress it”The design is complete. Now we test it where Instagram is hard: workers that crash, a celebrity post, stories that must vanish on time, and the bill for bytes. The last three add a small new piece each.
An upload pipeline that survives everything
Section titled “An upload pipeline that survives everything”Your queue delivers at least once and workers crash. Convince me no photo is lost, processed twice, or stuck forever.
Whether no photo is lost, processed twice in a harmful way, or stuck forever when workers crash.
Events arrive at least once, so the same “new photo” event can come twice. Workers crash halfway. So the worker treats the media row as the truth, and the event only as a hint to go look. Then it makes every step safe to repeat:
- Claim with a lease. A conditional update moves the row to
processing, with this worker as owner, a 2-minute lease, and the row’s version number plus one. Two workers can’t both succeed. A long video job renews its lease as it goes. - Predictable output names. Each file’s name comes from the media ID, the pipeline version and the size. So a retry sees what an earlier attempt finished, and skips it.
- Fence the finish. “Mark ready” only succeeds if the row still has this worker’s version. A worker that froze past its lease changes nothing when it wakes up.
- A retry limit. After 5 tries the file is marked
failed, for example a video that crashes the decoder, and the author is asked to try again. It never blocks the queue. - A sweeper. A job finds rows stuck in
uploadedorprocessingpast their lease, and queues them again. A lost event delays a photo, but can’t lose it.
// The media row is the truth; a queue message only says "look at media X". Delivery is at// least once, so this handler can run twice, at once, or after a crash halfway through.enum State { UPLOADING, UPLOADED, PROCESSING, READY, FAILED }record Media(String id, State state, String originalKey, String leaseOwner, long leaseUntil, int attempts, long version) {}record Rendition(String name, int maxEdge, String format) {}
interface MediaRows { Media get(String id); // Conditional updates: each runs only if the row still has the version we read, so two // workers can never both believe they own the job. boolean claim(String id, long version, String owner, long leaseUntil); // attempts + 1 boolean markReady(String id, long version, List<String> renditionKeys); boolean markFailed(String id, long version);}interface Blobs { boolean exists(String key); byte[] get(String key); void put(String key, byte[] bytes);}interface Renderer { byte[] render(byte[] original, Rendition r); }
class MediaWorker { // In every output key, so a new pipeline version writes new files and never overwrites // what the CDN is already serving under the old keys. static final int PIPELINE_VERSION = 3; static final long LEASE_MS = 120_000; // longer than any job; a dead worker's claim expires static final int MAX_ATTEMPTS = 5; // after that the input is poison, not unlucky static final List<Rendition> LADDER = List.of( new Rendition("1080", 1080, "webp"), new Rendition("640", 640, "webp"), new Rendition("320", 320, "webp"), new Rendition("thumb", 150, "jpg"));
private final MediaRows rows; private final Blobs blobs; private final Renderer renderer; private final String workerId; private final LongSupplier clock; // injected so tests can expire leases
MediaWorker(MediaRows rows, Blobs blobs, Renderer renderer, String workerId, LongSupplier clock) { this.rows = rows; this.blobs = blobs; this.renderer = renderer; this.workerId = workerId; this.clock = clock; }
static String keyOf(String mediaId, Rendition r) { return "media/" + mediaId + "/v" + PIPELINE_VERSION + "/" + r.name() + "." + r.format(); }
/** True: ack the message. False: leave it, and the queue redelivers it later. */ boolean handle(String mediaId) { Media m = rows.get(mediaId); if (m == null || m.state() == State.READY || m.state() == State.FAILED) return true; // deleted, or a duplicate of a finished job long now = clock.getAsLong(); if (m.state() == State.PROCESSING && m.leaseUntil() > now) return false; // someone holds a live lease; retry in case they die if (m.attempts() >= MAX_ATTEMPTS) { rows.markFailed(m.id(), m.version()); // the app tells the author to try again return true; } if (!rows.claim(m.id(), m.version(), workerId, now + LEASE_MS)) return false; // another worker claimed it between our read and now long mine = m.version() + 1;
List<String> keys = new ArrayList<>(); byte[] original = null; // fetched lazily: a fully rendered retry never reads it for (Rendition r : LADDER) { String key = keyOf(m.id(), r); keys.add(key); if (blobs.exists(key)) continue; // an earlier attempt got this far before crashing if (original == null) original = blobs.get(m.originalKey()); // Same input, same key: if two workers race here, either write is a valid result. blobs.put(key, renderer.render(original, r)); } // Fenced by version: a worker that stalled past its lease (a long GC pause) finds the // row moved on and changes nothing. Either way the message is done. rows.markReady(m.id(), mine, keys); return true; }}# The media row is the truth; a queue message only says "look at media X". Delivery is at# least once, so this handler can run twice, at once, or after a crash halfway through.class State(Enum): UPLOADING = "uploading" UPLOADED = "uploaded" PROCESSING = "processing" READY = "ready" FAILED = "failed"
@dataclass(frozen=True)class Media: id: str state: State original_key: str lease_owner: str | None lease_until: float attempts: int version: int
@dataclass(frozen=True)class Rendition: name: str max_edge: int format: str
class MediaRows(Protocol): def get(self, media_id: str) -> Media | None: ... # Conditional updates: each runs only if the row still has the version we read, so two # workers can never both believe they own the job. def claim(self, media_id: str, version: int, owner: str, lease_until: float) -> bool: ... def mark_ready(self, media_id: str, version: int, rendition_keys: list[str]) -> bool: ... def mark_failed(self, media_id: str, version: int) -> bool: ...
class Blobs(Protocol): def exists(self, key: str) -> bool: ... def get(self, key: str) -> bytes: ... def put(self, key: str, data: bytes) -> None: ...
class MediaWorker: # In every output key, so a new pipeline version writes new files and never overwrites # what the CDN is already serving under the old keys. PIPELINE_VERSION = 3 LEASE_SECONDS = 120.0 # longer than any job; a dead worker's claim expires MAX_ATTEMPTS = 5 # after that the input is poison, not unlucky LADDER = ( Rendition("1080", 1080, "webp"), Rendition("640", 640, "webp"), Rendition("320", 320, "webp"), Rendition("thumb", 150, "jpg"), )
def __init__(self, rows: MediaRows, blobs: Blobs, render: Callable[[bytes, Rendition], bytes], worker_id: str, clock: Callable[[], float] = time.time): # injected so tests can expire leases self._rows, self._blobs, self._render = rows, blobs, render self._worker_id, self._clock = worker_id, clock
@classmethod def key_of(cls, media_id: str, r: Rendition) -> str: return f"media/{media_id}/v{cls.PIPELINE_VERSION}/{r.name}.{r.format}"
# True: ack the message. False: leave it, and the queue redelivers it later. def handle(self, media_id: str) -> bool: m = self._rows.get(media_id) if m is None or m.state in (State.READY, State.FAILED): return True # deleted, or a duplicate of a finished job now = self._clock() if m.state is State.PROCESSING and m.lease_until > now: return False # someone holds a live lease; retry in case they die if m.attempts >= self.MAX_ATTEMPTS: self._rows.mark_failed(m.id, m.version) # the app tells the author to try again return True if not self._rows.claim(m.id, m.version, self._worker_id, now + self.LEASE_SECONDS): return False # another worker claimed it between our read and now mine = m.version + 1
keys: list[str] = [] original: bytes | None = None # fetched lazily: a fully rendered retry never reads it for r in self.LADDER: key = self.key_of(m.id, r) keys.append(key) if self._blobs.exists(key): continue # an earlier attempt got this far before crashing if original is None: original = self._blobs.get(m.original_key) # Same input, same key: if two workers race here, either write is a valid result. self._blobs.put(key, self._render(original, r)) # Fenced by version: a worker that stalled past its lease (a long GC pause) finds the # row moved on and changes nothing. Either way the message is done. self._rows.mark_ready(m.id, mine, keys) return True// The media row is the truth; a queue message only says "look at media X". Delivery is at// least once, so this handler can run twice, at once, or after a crash halfway through.enum MediaState { Uploading, Uploaded, Processing, Ready, Failed }record Media(string Id, MediaState State, string OriginalKey, string? LeaseOwner, DateTimeOffset LeaseUntil, int Attempts, long Version);record Rendition(string Name, int MaxEdge, string Format);
interface IMediaRows{ Media? Get(string id); // Conditional updates: each runs only if the row still has the version we read, so two // workers can never both believe they own the job. bool Claim(string id, long version, string owner, DateTimeOffset leaseUntil); // attempts + 1 bool MarkReady(string id, long version, IReadOnlyList<string> renditionKeys); bool MarkFailed(string id, long version);}
interface IBlobs{ bool Exists(string key); byte[] Get(string key); void Put(string key, byte[] bytes);}
interface IRenderer { byte[] Render(byte[] original, Rendition r); }
// The clock is injected so tests can expire leases.class MediaWorker(IMediaRows rows, IBlobs blobs, IRenderer renderer, string workerId, TimeProvider clock){ // In every output key, so a new pipeline version writes new files and never overwrites // what the CDN is already serving under the old keys. public const int PipelineVersion = 3; static readonly TimeSpan Lease = TimeSpan.FromMinutes(2); // longer than any job; a dead worker's claim expires const int MaxAttempts = 5; // after that the input is poison, not unlucky static readonly Rendition[] Ladder = [ new("1080", 1080, "webp"), new("640", 640, "webp"), new("320", 320, "webp"), new("thumb", 150, "jpg"), ];
public static string KeyOf(string mediaId, Rendition r) => $"media/{mediaId}/v{PipelineVersion}/{r.Name}.{r.Format}";
/// <summary>True: ack the message. False: leave it, and the queue redelivers it later.</summary> public bool Handle(string mediaId) { var m = rows.Get(mediaId); if (m is null || m.State is MediaState.Ready or MediaState.Failed) return true; // deleted, or a duplicate of a finished job var now = clock.GetUtcNow(); if (m.State == MediaState.Processing && m.LeaseUntil > now) return false; // someone holds a live lease; retry in case they die if (m.Attempts >= MaxAttempts) { rows.MarkFailed(m.Id, m.Version); // the app tells the author to try again return true; } if (!rows.Claim(m.Id, m.Version, workerId, now + Lease)) return false; // another worker claimed it between our read and now long mine = m.Version + 1;
var keys = new List<string>(); byte[]? original = null; // fetched lazily: a fully rendered retry never reads it foreach (var r in Ladder) { var key = KeyOf(m.Id, r); keys.Add(key); if (blobs.Exists(key)) continue; // an earlier attempt got this far before crashing original ??= blobs.Get(m.OriginalKey); // Same input, same key: if two workers race here, either write is a valid result. blobs.Put(key, renderer.Render(original, r)); } // Fenced by version: a worker that stalled past its lease (a long GC pause) finds the // row moved on and changes nothing. Either way the message is done. rows.MarkReady(m.Id, mine, keys); return true; }}// The media row is the truth; a queue message only says "look at media X". Delivery is at// least once, so this handler can run twice, at once, or after a crash halfway through.type State int
const ( Uploading State = iota Uploaded Processing Ready Failed)
type Media struct { ID, OriginalKey, LeaseOwner string State State LeaseUntil time.Time Attempts int Version int64}
type Rendition struct { Name string MaxEdge int Format string}
// MediaRows makes conditional updates: each runs only if the row still has the version we// read, so two workers can never both believe they own the job.type MediaRows interface { Get(id string) (*Media, error) // nil, nil when the media was deleted Claim(id string, version int64, owner string, leaseUntil time.Time) (bool, error) // attempts + 1 MarkReady(id string, version int64, renditionKeys []string) (bool, error) MarkFailed(id string, version int64) (bool, error)}
type Blobs interface { Exists(key string) (bool, error) Get(key string) ([]byte, error) Put(key string, data []byte) error}
// PipelineVersion is in every output key, so a new pipeline version writes new files and// never overwrites what the CDN is already serving under the old keys.const ( PipelineVersion = 3 LeaseFor = 2 * time.Minute // longer than any job; a dead worker's claim expires MaxAttempts = 5 // after that the input is poison, not unlucky)
var Ladder = []Rendition{{"1080", 1080, "webp"}, {"640", 640, "webp"}, {"320", 320, "webp"}, {"thumb", 150, "jpg"}}
type MediaWorker struct { Rows MediaRows Blobs Blobs Render func(original []byte, r Rendition) ([]byte, error) WorkerID string Now func() time.Time // injected so tests can expire leases}
func KeyOf(mediaID string, r Rendition) string { return fmt.Sprintf("media/%s/v%d/%s.%s", mediaID, PipelineVersion, r.Name, r.Format)}
// Handle returns true to ack the message. False, or an error, leaves it for redelivery.func (w *MediaWorker) Handle(mediaID string) (bool, error) { m, err := w.Rows.Get(mediaID) if err != nil { return false, err } if m == nil || m.State == Ready || m.State == Failed { return true, nil // deleted, or a duplicate of a finished job } now := w.Now() if m.State == Processing && m.LeaseUntil.After(now) { return false, nil // someone holds a live lease; retry in case they die } if m.Attempts >= MaxAttempts { _, err := w.Rows.MarkFailed(m.ID, m.Version) // the app tells the author to try again return err == nil, err } if ok, err := w.Rows.Claim(m.ID, m.Version, w.WorkerID, now.Add(LeaseFor)); !ok || err != nil { return false, err // another worker claimed it between our read and now } mine := m.Version + 1
keys := make([]string, 0, len(Ladder)) var original []byte // fetched lazily: a fully rendered retry never reads it for _, r := range Ladder { key := KeyOf(m.ID, r) keys = append(keys, key) if done, err := w.Blobs.Exists(key); err != nil { return false, err } else if done { continue // an earlier attempt got this far before crashing } if original == nil { if original, err = w.Blobs.Get(m.OriginalKey); err != nil { return false, err } } // Same input, same key: if two workers race here, either write is a valid result. out, err := w.Render(original, r) if err != nil { return false, err } if err := w.Blobs.Put(key, out); err != nil { return false, err } } // Fenced by version: a worker that stalled past its lease (a long GC pause) finds the // row moved on and changes nothing. Either way the message is done. _, err = w.Rows.MarkReady(m.ID, mine, keys) return err == nil, err}// The media row is the truth; a queue message only says "look at media X". Delivery is at// least once, so this handler can run twice, at once, or after a crash halfway through.type State = 'uploading' | 'uploaded' | 'processing' | 'ready' | 'failed';
interface Media { id: string; state: State; originalKey: string; leaseOwner: string | null; leaseUntil: number; attempts: number; version: number;}
interface Rendition { name: string; maxEdge: number; format: string;}
interface MediaRows { get(id: string): Promise<Media | null>; // Conditional updates: each runs only if the row still has the version we read, so two // workers can never both believe they own the job. claim(id: string, version: number, owner: string, leaseUntil: number): Promise<boolean>; // attempts + 1 markReady(id: string, version: number, renditionKeys: string[]): Promise<boolean>; markFailed(id: string, version: number): Promise<boolean>;}
interface Blobs { exists(key: string): Promise<boolean>; get(key: string): Promise<Uint8Array>; put(key: string, data: Uint8Array): Promise<void>;}
class MediaWorker { // In every output key, so a new pipeline version writes new files and never overwrites // what the CDN is already serving under the old keys. static readonly PIPELINE_VERSION = 3; static readonly LEASE_MS = 120_000; // longer than any job; a dead worker's claim expires static readonly MAX_ATTEMPTS = 5; // after that the input is poison, not unlucky static readonly LADDER: Rendition[] = [ { name: '1080', maxEdge: 1080, format: 'webp' }, { name: '640', maxEdge: 640, format: 'webp' }, { name: '320', maxEdge: 320, format: 'webp' }, { name: 'thumb', maxEdge: 150, format: 'jpg' }, ];
constructor( private readonly rows: MediaRows, private readonly blobs: Blobs, private readonly render: (original: Uint8Array, r: Rendition) => Promise<Uint8Array>, private readonly workerId: string, private readonly now: () => number = Date.now, // injected so tests can expire leases ) {}
static keyOf(mediaId: string, r: Rendition): string { return `media/${mediaId}/v${MediaWorker.PIPELINE_VERSION}/${r.name}.${r.format}`; }
/** True: ack the message. False: leave it, and the queue redelivers it later. */ async handle(mediaId: string): Promise<boolean> { const m = await this.rows.get(mediaId); if (!m || m.state === 'ready' || m.state === 'failed') return true; // deleted, or a duplicate of a finished job const now = this.now(); if (m.state === 'processing' && m.leaseUntil > now) return false; // someone holds a live lease; retry in case they die if (m.attempts >= MediaWorker.MAX_ATTEMPTS) { await this.rows.markFailed(m.id, m.version); // the app tells the author to try again return true; } if (!(await this.rows.claim(m.id, m.version, this.workerId, now + MediaWorker.LEASE_MS))) return false; // another worker claimed it between our read and now const mine = m.version + 1;
const keys: string[] = []; let original: Uint8Array | null = null; // fetched lazily: a fully rendered retry never reads it for (const r of MediaWorker.LADDER) { const key = MediaWorker.keyOf(m.id, r); keys.push(key); if (await this.blobs.exists(key)) continue; // an earlier attempt got this far before crashing original ??= await this.blobs.get(m.originalKey); // Same input, same key: if two workers race here, either write is a valid result. await this.blobs.put(key, await this.render(original, r)); } // Fenced by version: a worker that stalled past its lease (a long event-loop stall) finds // the row moved on and changes nothing. Either way the message is done. await this.rows.markReady(m.id, mine, keys); return true; }}- Color key
- Background worker
- Database
- Service
When the last photo of a pending post becomes ready, a listener flips the post to live with a
conditional update, and starts the feed copy. A duplicate event finds it already live and stops.
Go deeperOther ways to upload, and what a dropped connection costs1 min
| Upload method | A dropped connection costs | Bytes through our servers | Fits |
|---|---|---|---|
| One POST to our API | The whole file | All of them | A small app with small photos |
| One presigned PUT | The whole file | None | Photos on good networks |
| Presigned parts, 8 MB each | One part | None | Everything here |
| A resumable protocol (tus) | Bytes since the last offset | None, if storage supports it | Stores that support it natively |
Uploads that are abandoned halfway are cleaned up automatically by a storage rule after a day.
A million likes in a minute
Section titled “A million likes in a minute”A footballer with 600 million followers posts a photo. In the first minute it gets a million likes. What happens to the database?
Total like traffic is fine: 140,000 a second spread over millions of posts. The problem is one
post at 20,000 a second. If each like runs UPDATE posts SET likes = likes + 1, every update to
that row waits for the one before it. One row manages about a thousand a second.
So the count moves to Redis, split into slots: likes:<postId>:<slot>. A like adds one to a
random slot. A read adds up the slots. Most posts have one slot. Once a post passes a few hundred
likes a second, it’s raised to 64 slots, so 20,000 likes a second become about 300 per slot, spread
across many machines. Each API server caches the total for 2 seconds, so the hot read is one batched
read per server, not one per viewer.
Stage 7: Hot counters
Stage 7 of 9
Hot counters
Why this stage
Not the slider: a burst.
A celebrity post can take 20,000 likes a second, all incrementing one row, and one Postgres row takes about 1,000 contended updates a second.
What we add
Like and comment counts move to a counter store, split into up to 64 slots per hot post.
The like edge stays in the liker’s shard and decides whether a like counts.
What it costs
Counts are approximate for a few seconds, and a crash between the edge and the counter can drift a count until reconciliation.
At 3.5M media views per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
How busy it gets
- For every 300 media views, someone uploads 1 photo or video.
- There is about one API request for every 10 views, and one feed load for every 20.
- Each view needs about one metadata lookup, for the post around it.
What one machine can do
- One app server serves about 5,000 small files a second and 500 uploads when it handles the bytes itself. Without the bytes, it handles about 10,000 metadata requests.
- One Postgres shard serves about 10,000 reads a second and 8,000 writes. There are 64 shards.
- One 64-core media worker handles about 1.6 uploads a second, because video dominates: 20 seconds of video at 8 core-seconds per second.
- The feed service handles about 4,000 loads a second per server.
How big the caches are
- The metadata cache is 30 Redis nodes, about 200,000 lookups a second each. It answers 95% of lookups.
- The counter store is 20 Redis nodes.
Why writes add up
- Each upload brings about 28 metadata writes: the post, comments, follows and about 13 likes.
- Until stage 7, each like is an edge plus a counter update.
The like decides, the counter follows. The API inserts the like row with “do nothing if it already exists”, and adds to the counter only if a row was actually created. So a retry or a double tap adds nothing.
// The like edge is the truth; the counter is a fast, approximate view of how many edges exist.interface Likes { // INSERT ... ON CONFLICT DO NOTHING on the liker's shard: true only if a row was created. boolean insertIfAbsent(long userId, long postId); boolean deleteIfPresent(long userId, long postId);}interface CounterStore { // Redis: INCRBY, and GETs pipelined per node void incrBy(String key, long delta); List<Long> getAll(List<String> keys); // a missing key reads as 0}interface HotPosts { // 1 for almost every post. A detector raises it (up to 64) when a post takes more than a // few hundred likes a second, and only lowers it after the post has been cold for a day, // so readers never stop summing a slot that still holds likes. int slots(long postId);}
class LikeCounter { static final long READ_TTL_MS = 2_000; // how stale a displayed count may be
private record Cached(long value, long expiresAt) {}
private final Likes likes; private final CounterStore store; private final HotPosts hot; private final LongSupplier clock; // injected so tests control the cache private final Map<Long, Cached> local = new ConcurrentHashMap<>();
LikeCounter(Likes likes, CounterStore store, HotPosts hot, LongSupplier clock) { this.likes = likes; this.store = store; this.hot = hot; this.clock = clock; }
// No {hash tag} in the key on purpose: Redis Cluster would put every slot of a post on // the same node, which is exactly the hot spot the slots exist to spread. static String key(long postId, int slot) { return "likes:" + postId + ":" + slot; }
void like(long userId, long postId) { // The edge decides whether this like counts, so a retry or a double tap adds nothing. // A crash between the two writes loses one increment; reconciliation recounts edges. if (likes.insertIfAbsent(userId, postId)) add(postId, 1); }
void unlike(long userId, long postId) { if (likes.deleteIfPresent(userId, postId)) add(postId, -1); }
private void add(long postId, long delta) { // A random slot spreads one post's writes over many keys, and so over many nodes: // 20,000 likes a second become about 300 per slot with 64 slots. int slot = ThreadLocalRandom.current().nextInt(hot.slots(postId)); store.incrBy(key(postId, slot), delta); }
long count(long postId) { // A hot post is read far more than it is liked: one batched read per server every // 2 seconds, not one per viewer. Two servers may show counts a second apart. long now = clock.getAsLong(); Cached c = local.get(postId); if (c != null && c.expiresAt() > now) return c.value(); List<String> keys = new ArrayList<>(); for (int s = 0; s < hot.slots(postId); s++) keys.add(key(postId, s)); long sum = 0; for (long v : store.getAll(keys)) sum += v; sum = Math.max(0, sum); // an unlike can land on a slot before its like does local.put(postId, new Cached(sum, now + READ_TTL_MS)); return sum; }}# The like edge is the truth; the counter is a fast, approximate view of how many edges exist.class Likes(Protocol): # INSERT ... ON CONFLICT DO NOTHING on the liker's shard: True only if a row was created. def insert_if_absent(self, user_id: int, post_id: int) -> bool: ... def delete_if_present(self, user_id: int, post_id: int) -> bool: ...
class CounterStore(Protocol): # Redis: INCRBY, and GETs pipelined per node def incr_by(self, key: str, delta: int) -> None: ... def get_all(self, keys: list[str]) -> list[int]: ... # a missing key reads as 0
class HotPosts(Protocol): # 1 for almost every post. A detector raises it (up to 64) when a post takes more than a # few hundred likes a second, and only lowers it after the post has been cold for a day, # so readers never stop summing a slot that still holds likes. def slots(self, post_id: int) -> int: ...
class LikeCounter: READ_TTL = 2.0 # how stale a displayed count may be, in seconds
def __init__(self, likes: Likes, store: CounterStore, hot: HotPosts, clock: Callable[[], float] = time.monotonic): # injected so tests control the cache self._likes, self._store, self._hot, self._clock = likes, store, hot, clock self._local: dict[int, tuple[int, float]] = {}
@staticmethod def key(post_id: int, slot: int) -> str: # No {hash tag} in the key on purpose: Redis Cluster would put every slot of a post on # the same node, which is exactly the hot spot the slots exist to spread. return f"likes:{post_id}:{slot}"
def like(self, user_id: int, post_id: int) -> None: # The edge decides whether this like counts, so a retry or a double tap adds nothing. # A crash between the two writes loses one increment; reconciliation recounts edges. if self._likes.insert_if_absent(user_id, post_id): self._add(post_id, 1)
def unlike(self, user_id: int, post_id: int) -> None: if self._likes.delete_if_present(user_id, post_id): self._add(post_id, -1)
def _add(self, post_id: int, delta: int) -> None: # A random slot spreads one post's writes over many keys, and so over many nodes: # 20,000 likes a second become about 300 per slot with 64 slots. slot = random.randrange(self._hot.slots(post_id)) self._store.incr_by(self.key(post_id, slot), delta)
def count(self, post_id: int) -> int: # A hot post is read far more than it is liked: one batched read per server every # 2 seconds, not one per viewer. Two servers may show counts a second apart. now = self._clock() hit = self._local.get(post_id) if hit and hit[1] > now: return hit[0] keys = [self.key(post_id, s) for s in range(self._hot.slots(post_id))] total = max(0, sum(self._store.get_all(keys))) # an unlike can land on a slot before its like self._local[post_id] = (total, now + self.READ_TTL) return total// The like edge is the truth; the counter is a fast, approximate view of how many edges exist.interface ILikes{ // INSERT ... ON CONFLICT DO NOTHING on the liker's shard: true only if a row was created. bool InsertIfAbsent(long userId, long postId); bool DeleteIfPresent(long userId, long postId);}
interface ICounterStore // Redis: INCRBY, and GETs pipelined per node{ void IncrBy(string key, long delta); long[] GetAll(IReadOnlyList<string> keys); // a missing key reads as 0}
interface IHotPosts{ // 1 for almost every post. A detector raises it (up to 64) when a post takes more than a // few hundred likes a second, and only lowers it after the post has been cold for a day, // so readers never stop summing a slot that still holds likes. int Slots(long postId);}
// The clock is injected so tests control the cache.class LikeCounter(ILikes likes, ICounterStore store, IHotPosts hot, TimeProvider clock){ static readonly TimeSpan ReadTtl = TimeSpan.FromSeconds(2); // how stale a displayed count may be
private record Cached(long Value, DateTimeOffset ExpiresAt); private readonly ConcurrentDictionary<long, Cached> _local = new();
// No {hash tag} in the key on purpose: Redis Cluster would put every slot of a post on // the same node, which is exactly the hot spot the slots exist to spread. public static string Key(long postId, int slot) => $"likes:{postId}:{slot}";
public void Like(long userId, long postId) { // The edge decides whether this like counts, so a retry or a double tap adds nothing. // A crash between the two writes loses one increment; reconciliation recounts edges. if (likes.InsertIfAbsent(userId, postId)) Add(postId, 1); }
public void Unlike(long userId, long postId) { if (likes.DeleteIfPresent(userId, postId)) Add(postId, -1); }
private void Add(long postId, long delta) { // A random slot spreads one post's writes over many keys, and so over many nodes: // 20,000 likes a second become about 300 per slot with 64 slots. int slot = Random.Shared.Next(hot.Slots(postId)); store.IncrBy(Key(postId, slot), delta); }
public long Count(long postId) { // A hot post is read far more than it is liked: one batched read per server every // 2 seconds, not one per viewer. Two servers may show counts a second apart. var now = clock.GetUtcNow(); if (_local.TryGetValue(postId, out var c) && c.ExpiresAt > now) return c.Value; var keys = Enumerable.Range(0, hot.Slots(postId)).Select(s => Key(postId, s)).ToList(); long sum = Math.Max(0, store.GetAll(keys).Sum()); // an unlike can land on a slot before its like _local[postId] = new Cached(sum, now + ReadTtl); return sum; }}// The like edge is the truth; the counter is a fast, approximate view of how many edges exist.type Likes interface { // INSERT ... ON CONFLICT DO NOTHING on the liker's shard: true only if a row was created. InsertIfAbsent(userID, postID int64) (bool, error) DeleteIfPresent(userID, postID int64) (bool, error)}
type CounterStore interface { // Redis: INCRBY, and GETs pipelined per node IncrBy(key string, delta int64) error GetAll(keys []string) ([]int64, error) // a missing key reads as 0}
// HotPosts returns 1 for almost every post. A detector raises it (up to 64) when a post takes// more than a few hundred likes a second, and only lowers it after the post has been cold for// a day, so readers never stop summing a slot that still holds likes.type HotPosts interface{ Slots(postID int64) int }
const ReadTTL = 2 * time.Second // how stale a displayed count may be
type cached struct { value int64 expiresAt time.Time}
type LikeCounter struct { Likes Likes Store CounterStore Hot HotPosts Now func() time.Time // injected so tests control the cache
mu sync.Mutex local map[int64]cached}
// Key has no {hash tag} on purpose: Redis Cluster would put every slot of a post on the same// node, which is exactly the hot spot the slots exist to spread.func Key(postID int64, slot int) string { return fmt.Sprintf("likes:%d:%d", postID, slot) }
func (c *LikeCounter) Like(userID, postID int64) error { // The edge decides whether this like counts, so a retry or a double tap adds nothing. // A crash between the two writes loses one increment; reconciliation recounts edges. created, err := c.Likes.InsertIfAbsent(userID, postID) if err != nil || !created { return err } return c.add(postID, 1)}
func (c *LikeCounter) Unlike(userID, postID int64) error { removed, err := c.Likes.DeleteIfPresent(userID, postID) if err != nil || !removed { return err } return c.add(postID, -1)}
func (c *LikeCounter) add(postID, delta int64) error { // A random slot spreads one post's writes over many keys, and so over many nodes: // 20,000 likes a second become about 300 per slot with 64 slots. slot := rand.IntN(c.Hot.Slots(postID)) return c.Store.IncrBy(Key(postID, slot), delta)}
func (c *LikeCounter) Count(postID int64) (int64, error) { // A hot post is read far more than it is liked: one batched read per server every // 2 seconds, not one per viewer. Two servers may show counts a second apart. now := c.Now() c.mu.Lock() hit, ok := c.local[postID] c.mu.Unlock() if ok && hit.expiresAt.After(now) { return hit.value, nil } n := c.Hot.Slots(postID) keys := make([]string, n) for s := range n { keys[s] = Key(postID, s) } values, err := c.Store.GetAll(keys) if err != nil { return 0, err } var sum int64 for _, v := range values { sum += v } sum = max(0, sum) // an unlike can land on a slot before its like does c.mu.Lock() if c.local == nil { c.local = map[int64]cached{} } c.local[postID] = cached{sum, now.Add(ReadTTL)} c.mu.Unlock() return sum, nil}// The like edge is the truth; the counter is a fast, approximate view of how many edges exist.interface Likes { // INSERT ... ON CONFLICT DO NOTHING on the liker's shard: true only if a row was created. insertIfAbsent(userId: bigint, postId: bigint): Promise<boolean>; deleteIfPresent(userId: bigint, postId: bigint): Promise<boolean>;}
interface CounterStore { // Redis: INCRBY, and GETs pipelined per node incrBy(key: string, delta: number): Promise<void>; getAll(keys: string[]): Promise<number[]>; // a missing key reads as 0}
interface HotPosts { // 1 for almost every post. A detector raises it (up to 64) when a post takes more than a // few hundred likes a second, and only lowers it after the post has been cold for a day, // so readers never stop summing a slot that still holds likes. slots(postId: bigint): number;}
class LikeCounter { static readonly READ_TTL_MS = 2_000; // how stale a displayed count may be private readonly local = new Map<bigint, { value: number; expiresAt: number }>();
constructor( private readonly likes: Likes, private readonly store: CounterStore, private readonly hot: HotPosts, private readonly now: () => number = Date.now, // injected so tests control the cache ) {}
// No {hash tag} in the key on purpose: Redis Cluster would put every slot of a post on // the same node, which is exactly the hot spot the slots exist to spread. static key(postId: bigint, slot: number): string { return `likes:${postId}:${slot}`; }
async like(userId: bigint, postId: bigint): Promise<void> { // The edge decides whether this like counts, so a retry or a double tap adds nothing. // A crash between the two writes loses one increment; reconciliation recounts edges. if (await this.likes.insertIfAbsent(userId, postId)) await this.add(postId, 1); }
async unlike(userId: bigint, postId: bigint): Promise<void> { if (await this.likes.deleteIfPresent(userId, postId)) await this.add(postId, -1); }
private add(postId: bigint, delta: number): Promise<void> { // A random slot spreads one post's writes over many keys, and so over many nodes: // 20,000 likes a second become about 300 per slot with 64 slots. const slot = Math.floor(Math.random() * this.hot.slots(postId)); return this.store.incrBy(LikeCounter.key(postId, slot), delta); }
async count(postId: bigint): Promise<number> { // A hot post is read far more than it is liked: one batched read per server every // 2 seconds, not one per viewer. Two servers may show counts a second apart. const now = this.now(); const hit = this.local.get(postId); if (hit && hit.expiresAt > now) return hit.value; const keys = Array.from({ length: this.hot.slots(postId) }, (_, s) => LikeCounter.key(postId, s), ); const values = await this.store.getAll(keys); const sum = Math.max( 0, values.reduce((a, b) => a + b, 0), ); // an unlike can land on a slot before its like this.local.set(postId, { value: sum, expiresAt: now + LikeCounter.READ_TTL_MS }); return sum; }}A crash between the two writes can lose a few counts. So an hourly job recounts hot posts from the like rows and corrects the slots. Counts above 10,000 are shown rounded (“1.2M”), which hides that small drift. Follow a like, and a view of the post:
Trace a request: Start an upload
Start an upload: step 1 of 7
Users to Load balancer
POST /v1/uploads with the content type, size and a client-generated upload key. The phone has already resized the photo to 1080 px and stripped location metadata if the user asked.
- Users → Load balancer30 ms
- Load balancer → API servers1 ms
- API servers → Metadata DB5 ms
- Users → Object storageasync
- Object storage → Media queueasync
- Media queue → Media workersasync
- Response
Go deeperOther ways to count a hot post1 min
| Approach | Writes on the hot key | Count freshness | Breaks on |
|---|---|---|---|
| One counter row per post | 1, one at a time | Exact, immediate | About a thousand likes a second |
| Split counter, N slots | 1 on a random slot | About 2 seconds | Reads must add N keys |
| Log, then add up per second | 1 per batch | Seconds to a minute | A stuck consumer freezes counts |
| Count the likes on read | 0 | Exact | Counting 10 million rows on every view |
Why not just one Redis key? It could take 20,000 increments a second. But it would pin the post to one core on one machine that also serves other keys, and a post ten times more viral wouldn’t fit. Billing by likes would need the log design with exact accounting.
Stories that vanish on time
Section titled “Stories that vanish on time”Stories disappear after 24 hours. How does that happen for 200 million stories a day?
Two things are hard. The tray, the row of circles at the top of the app, loads on every app open, about 90,000 times a second. It shows which of up to 7,500 people you follow have a story right now. And expiry must be exact for viewers, but cheap for storage.
The tray is built when you open the app. Each API server keeps an in-memory map from each author to their newest live story time. That’s about 100 million authors at 16 bytes, under 2 GB, kept current from the story event stream. A tray load matches your following list against it, and ranks by how often you watch each author. Pushing every story into hundreds of trays, only to expire a day later, would cost far more.
Stage 8: Stories that expire
Stage 8 of 9
Stories that expire
Why this stage
The product requirement: stories disappear after 24 hours, and the tray at the top of the app must load with the app.
What we add
A stories store bucketed by hour, an in-memory index of who has an active story, and lifecycle rules that delete story renditions after expiry.
What it costs
Expiry is enforced at read time, so every story read checks a timestamp, and highlights must pin their media before the cleanup reaches it.
At 3.5M media views per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
How busy it gets
- For every 300 media views, someone uploads 1 photo or video.
- There is about one API request for every 10 views, and one feed load for every 20.
- Each view needs about one metadata lookup, for the post around it.
What one machine can do
- One app server serves about 5,000 small files a second and 500 uploads when it handles the bytes itself. Without the bytes, it handles about 10,000 metadata requests.
- One Postgres shard serves about 10,000 reads a second and 8,000 writes. There are 64 shards.
- One 64-core media worker handles about 1.6 uploads a second, because video dominates: 20 seconds of video at 8 core-seconds per second.
- The feed service handles about 4,000 loads a second per server.
How big the caches are
- The metadata cache is 30 Redis nodes, about 200,000 lookups a second each. It answers 95% of lookups.
- The counter store is 20 Redis nodes.
Why writes add up
- Each upload brings about 28 metadata writes: the post, comments, follows and about 13 likes.
- Until stage 7, each like is an edge plus a counter update.
Expiry is checked on every read. Every read filters on expires_at with the server’s clock. So
a story is never shown after 24 hours, even if cleanup is days late. Cleanup then happens in bulk.
Story rows are stored in hourly buckets, and a bucket older than 25 hours is dropped whole, far
cheaper than 8 million single deletes. Story files live under a daily folder that a storage rule
deletes two days later.
Caches must expire too. Story files are cached only for the time left until expires_at, not the
year feed photos get, and their signed links expire at the same moment. So neither a CDN copy nor
a copied link outlives the story. Highlights, stories the author pins to their profile, are
copied to permanent storage when pinned, so the bulk cleanup never has to check.
Go deeperOther ways to expire a story1 min
| How a story expires | Exact for viewers | Storage cost | Problem |
|---|---|---|---|
| A delete job scheduled per story | Only if the job runs | 200 million timers a day | A missed job shows a story forever |
| A database expiry per row | Yes, if reads check it | A delete marker per row | Doesn’t handle highlights |
| Check on read, drop hourly buckets | Yes, always | One bucket drop an hour | Expired stories sit on disk a while |
A user deleting their own story takes a separate, immediate path: mark the row deleted and purge the CDN.
The bill for bytes
Section titled “The bill for bytes”The CDN invoice is the biggest line in the budget, and storage grows every month. Where does the money go?
Bytes served come first. The app asks for the width it will actually draw: 320 pixels in a profile grid, not 1080, which is a tenth of the bytes. Newer formats, WebP and then AVIF, save a quarter to a third against JPEG. Videos start at low quality, and only the first seconds are preloaded, because most videos are never finished. At 1,000 PB a month, making the average image 10% smaller saves about 100 PB a month.
Storage comes next. Views of a photo bunch up in its first days. A year later, most photos are rarely seen. Yet three copies of 1.3 exabytes is about 3.9 exabytes of disk. So older media moves to a warm tier that uses erasure coding: the data is split into 10 blocks plus 4 extra parity blocks, and any 10 of the 14 can rebuild it. That survives 4 losses, and costs 1.4 times the data instead of 3. Facebook’s f4 system did this for older photos.
Stage 9: Cold media
Stage 9 of 9
Cold media
Why this stage
Not the slider: the bill.
Feed media grows by about 700 TB a day, 1,300 PB over five years, and most of it is rarely viewed after its first month.
What we add
A warm storage tier with erasure coding.
Media older than 90 days that is rarely read moves there; hot media stays replicated.
What it costs
A view of old media that misses the CDN is slower, about 100 ms more, and the tiering job is one more thing that can lose data if it is wrong.
At 3.5M media views per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
How busy it gets
- For every 300 media views, someone uploads 1 photo or video.
- There is about one API request for every 10 views, and one feed load for every 20.
- Each view needs about one metadata lookup, for the post around it.
What one machine can do
- One app server serves about 5,000 small files a second and 500 uploads when it handles the bytes itself. Without the bytes, it handles about 10,000 metadata requests.
- One Postgres shard serves about 10,000 reads a second and 8,000 writes. There are 64 shards.
- One 64-core media worker handles about 1.6 uploads a second, because video dominates: 20 seconds of video at 8 core-seconds per second.
- The feed service handles about 4,000 loads a second per server.
How big the caches are
- The metadata cache is 30 Redis nodes, about 200,000 lookups a second each. It answers 95% of lookups.
- The counter store is 20 Redis nodes.
Why writes add up
- Each upload brings about 28 metadata writes: the post, comments, follows and about 13 likes.
- Until stage 7, each like is an edge plus a counter update.
A tiering job moves media older than 90 days that is rarely viewed, and moves it back if it turns popular. It deletes the hot copy only after the coded copy is read back and checked. By year five, that saves about 1.2 exabytes of disk.
Go deeperThe three storage tiers, and the cache levers1 min
| Tier | Copies of the data | Read time on a miss | Holds |
|---|---|---|---|
| Hot, copied | 3 | About 30 ms | New and popular media, live stories |
| Warm, erasure coded | About 2.1 | About 100 ms | Older media that is rarely viewed |
| Archive | About 1.2 | Minutes to hours | Story archives, holds for deleted accounts |
Hit rate. Every point of CDN hit rate at 3.5 million views a second is 35,000 fewer reads from storage. Unchanging URLs cached for a year, plus a shared cache layer in each region, keep it high. At this volume, the cheapest bandwidth is cache boxes placed inside internet providers’ own networks.
Private media. A shared cache can’t ask who you are. So private media links carry an expiry and a signature that the edge checks before serving its cached copy. The cache ignores the signature when matching, so viewers share one copy. A copied link dies at its expiry.
When things break
Section titled “When things break”The media workers fall an hour behind on a busy evening. What do users see?
Whether uploads still succeed, posts wait instead of failing, and nothing is lost.
The processing pipeline backs up. Uploads still succeed, because the bytes go straight to
storage and the media row is already written. Every “new object” event waits in the media queue, so
nothing is lost. Posts stay pending and go live when their last version is ready, so followers see
them late. The author’s app shows the post from the local file, so the author barely notices.
Workers are added as the oldest event in the queue gets older, and the slowest work, the 1080p video
versions, can wait until the backlog clears.
Object storage is down. Viewing survives almost anything. About 95% of views are CDN hits, and on a miss the CDN switches to the copy in another region. Uploads wait instead of failing: the app keeps them in its outbox and resumes the parts later. Nothing posted is lost.
Click any box to break it and see what users would notice:
Break it: what fails, who notices, what users see
Failure: click any box to break it
Object storage goes down
What breaks
Uploads cannot start or finish in one region, and CDN misses fail there.
How we notice
Upload completion rate and origin error rate.
What users see
New posts wait in the app’s outbox.
Viewing still works for anything at the edge, which is 95% of views.
What we do
The app resumes the multipart upload when storage returns.
Originals are replicated to a second region within minutes, and the CDN fails over to it.
Losing data matters most, so it’s defended in layers. Originals are in two separate places before the upload is accepted, and in a second region within minutes. Tiering checks before it deletes. And deleted media rows are kept for 30 days, so a buggy delete can be undone.
Under stress, shed in order: Explore and search first, then fresh counts, then high video quality, then tray ranking. Viewing posts and accepting uploads go last.
At ten times the load, the cache, shards, counters and stories store simply grow. The real changes are in bytes and compute. Egress reaches 14 TB a second, so the edge moves inside internet providers. Video work passes a million cores, so encoding moves to special hardware, and videos get just two sizes at upload, with the rest made once they earn views.
Go deeperEvery failure, and what users see2 min
| Part | Failure | What users see | What we do |
|---|---|---|---|
| Object storage | Region down | Uploads wait; 95% of views still hit the CDN | Resume parts later; CDN uses the other region |
| Media workers | Crash, bad file | Slower processing; a bad file fails alone | Leases, predictable names, 5 tries |
| Media queue | Down or behind | Posts stay pending longer | The sweeper queues from the media table |
| Metadata shard | Primary down | 1.5% of users can’t write for about 30 s | Replica takes over; retries are safe |
| Counter store | Down | Counts frozen or hidden; likes still work | Buffer and replay; recount from the like rows |
| Stories store | Down | Tray empty or stale; feed fine | Replica takes over; expiry still checked on read |
| CDN location | Down | Images slow in one area | DNS steering; a second CDN always in use |
| Whole region | Outage | API moves in 1 to 2 min; images keep loading | Promote that region’s shards elsewhere |
What users can see. An author always sees their own post at once, because their profile reads the main copy for a minute after posting. Followers see it 1 to 5 seconds later. Whether you liked a post is exact, so the heart never flips back. The count can be 2 seconds behind.
Go deeperRunning it: alerts1 min
Wake someone up when: fewer than 99% of uploads go live within 15 minutes, per platform; photo processing takes more than 10 seconds, or video more than 120; the apps report media load failures above 0.1% or slow first bytes on any CDN location; the CDN byte hit rate falls below 93%, which is a bill before it’s an outage; or post reads pass 200 ms.
Wrap up
Section titled “Wrap up”The whole design
Section titled “The whole design”Here is everything we built, in the order we built it. Use the buttons to step through it.
The full design, stage by stage
Stage 9 of 9
Cold media
Why this stage
Not the slider: the bill.
Feed media grows by about 700 TB a day, 1,300 PB over five years, and most of it is rarely viewed after its first month.
What we add
A warm storage tier with erasure coding.
Media older than 90 days that is rarely read moves there; hot media stays replicated.
What it costs
A view of old media that misses the CDN is slower, about 100 ms more, and the tiering job is one more thing that can lose data if it is wrong.
At 3.5M media views per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
How busy it gets
- For every 300 media views, someone uploads 1 photo or video.
- There is about one API request for every 10 views, and one feed load for every 20.
- Each view needs about one metadata lookup, for the post around it.
What one machine can do
- One app server serves about 5,000 small files a second and 500 uploads when it handles the bytes itself. Without the bytes, it handles about 10,000 metadata requests.
- One Postgres shard serves about 10,000 reads a second and 8,000 writes. There are 64 shards.
- One 64-core media worker handles about 1.6 uploads a second, because video dominates: 20 seconds of video at 8 core-seconds per second.
- The feed service handles about 4,000 loads a second per server.
How big the caches are
- The metadata cache is 30 Redis nodes, about 200,000 lookups a second each. It answers 95% of lookups.
- The counter store is 20 Redis nodes.
Why writes add up
- Each upload brings about 28 metadata writes: the post, comments, follows and about 13 likes.
- Until stage 7, each like is an edge plus a counter update.
The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| Decision | We chose | We gave up | We’d choose differently if |
|---|---|---|---|
| Uploads | Presigned parts straight to storage | Checking files before the bytes arrive | Uploads were tiny and rare |
| Processing | After the upload, every step safe to repeat | A post exists before its media | Users could wait on a spinner |
| Metadata | Postgres by user, the shard inside every ID | A user can never change shards | Most queries were by post, not user |
| Like counts | Split Redis counter, the like row is the truth | Exact counts at every instant | Likes were billed |
| Stories | Checked on read, cleaned up in hourly buckets | Expired bytes sit on disk a while | Stories were permanent |
| Old media | Erasure-coded warm tier | Slower reads on a rare miss | Storage were cheap next to engineering time |
| The feed | The news feed design, starting after ready |
Nothing new | The interviewer wanted the feed (go there) |
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 0 to 4. Scope: which features, how much video, and say you’ll focus on media.
- 4 to 9. 42 GB/s in, 1.4 TB/s out, 700 TB a day, 290,000 metadata writes a second.
- 9 to 20. Build it: one server, then direct uploads, processing after, the CDN, sharding by user with the shard in the ID, the feed in two sentences. A mid-level answer can stop here.
- 20 to 29. The stress tests: the upload pipeline under crashes, then the hot like counter.
- 29 to 40. Stories, then the bill for bytes, as the interviewer leans.
- 40 to 45. Storage going down, then your own questions.
Never skip direct uploads, a pipeline that’s safe to repeat, or the hot counter.
Follow-up questions
Section titled “Follow-up questions”Interviewer asksA user deletes a post. How fast is it gone?
From the API on the next read, because the row is marked deleted. The CDN is purged within minutes. The files are removed later by a cleanup job, and kept for 30 days in case the delete was a bug.
Interviewer asksHow would you add a filter preview before upload?
On the phone. Filters run on the device’s graphics chip on the local photo, and the phone uploads the finished image. The server never renders previews, which would cost a round trip per swipe.
Go deeperMore follow-up questions1 min
A user says their photo is stuck on ‘processing’. Where do you look? The media row’s state and
attempts. Stuck in uploaded means the event was lost, and the sweeper will pick it up. Stuck in
processing past its lease means a worker died, and the sweeper also covers it. failed after 5
tries means a file the decoder can’t read, and the app asks the user to retry.
I post and refresh my profile. Why can’t I see my post? You should always see it: for a minute after you post, your own profile reads from your shard’s main copy, not a replica that may be a second behind. This is called reading your own writes. Your followers read from replicas and the cache, and see the post a second or two later, which nobody notices.
Comments on a viral post: newest first, or top first? Both, as two views over the same rows on the post owner’s shard. Newest is a range read by comment ID, which already sorts by time. Top comments are ranked by a background job every few minutes and cached, because exact ranking on every read isn’t worth the cost.
How do you stop people from scraping private photos? Private media links are signed and expire, so a copied link dies within a day. The API checks that the viewer follows the account before handing out links. And rate limits per account and address slow down bulk downloading.
Further reading
Section titled “Further reading”- The news feed for fan-out and ranking, the URL shortener for hot keys and single flight, and caching on this site.
- Sharding & IDs at Instagram, Instagram engineering: 64 bit IDs with the logical shard inside, on Postgres schemas.
- Finding a needle in Haystack: Facebook’s photo storage, OSDI 2010: why per-photo filesystem metadata was the bottleneck, and one disk read per photo.
- f4: Facebook’s Warm BLOB Storage System, OSDI 2014: erasure coding for media past its hot period, at an effective replication of 2.1.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.