Design Pinterest
Save images to boards, browse a personalised grid and search by picture: 100 M daily users, 5 B distinct images behind 100 B saves.
Last updated 2026-10-27. Difficulty: hard. Patterns: random-walks, visual-search, deduplication, vector-search, image-serving. Reported at Pinterest, Meta, Snap, Google, Amazon, Instacart.
The interviewer asks, the candidate answers and draws, and you press Next. Skip to any stage or key moment, and ask the AI Mentor anything along the way.
Or sit it in the style of Pinterest · Meta · Snap · Google, run as that company’s round is reported to go.
The short answer
How to design Pinterest in a system design interview, in five steps:
- Scope it. Save a pin to a board, boards and follows, home feed, more like this, text search, visual search, new pins discoverable fast, spam and low quality kept out.
- Set the targets. Scale: 100 M DAU · 50 k pages/s peak; Home feed latency and availability: p99 < 300 ms · 99.95 % served; Graph candidates from RAM: walk p99 < 50 ms; Visual search: p99 < 500 ms over ~10 B vectors.
- Do the numbers. Home feed pages: ~17 k/s avg · ~50 k/s peak; Saves: ~230/s avg · ~700/s peak; Image storage: ~3 PB deduplicated (~60 PB per save).
- Draw the design. Image CDN, Pin service, Image service, Object storage, Pin store, Event bus, Home feed service, Feed cache, Graph walk service.
- Go deep where it is hard.
- A pin is a save of an image → Canonical image by exact hash, near-duplicates folded in by perceptual hash, pins referencing it.
- Random walks on the pin-board graph → Random walks with restart on a pruned graph held whole in RAM on every server.
- Building the home feed → Per-session funnel (walks, follows, embeddings), ranked and blended once, served in pages from a 30-minute cache.
- Visual search over 10 B vectors → IVF-PQ in RAM, 24 shards by image id, full-vector re-rank from NVMe, plus an HNSW fresh tier.
- Images in a masonry grid → Fixed widths generated at upload, immutable URLs on a CDN, and width, height and dominant colour in every pin.
- Fresh pins in, spam pins out → Small real-time tiers merged at query time, removals as a bitmap filter, big tiers rebuilt and swapped.
Functional requirements
- Save a pin to a board. A pin is a save: an image, a link and a description, placed on one of your boards. Most saves re-save an image we already hold, so a save must never copy image bytes.
- Boards and follows. Create boards, follow people or single boards. Followed pins are one slice of the home feed, not the whole of it.
- Home feed. A personalised masonry grid, 25 pins a page, scrolling without end. Most pins come from people the viewer does not follow, and the grid must lay out before any image arrives.
- More like this. Open a pin and see related pins below it. The answer depends on the pin far more than on the viewer, which is what makes it cacheable.
- Text search. “brass floor lamp” finds pins whose words match, and also pins whose picture matches but whose description only says “my reading corner”.
- Visual search. Tap a detected object or draw a box on any image, or point the camera, and get visually similar pins: the lamp in someone’s living-room photo.
- New pins discoverable fast. A fresh pin shows in followers’ feeds and in search within minutes, even though the big indexes are rebuilt only daily or weekly.
- Spam and low quality kept out. Pins that link to spam, malware or cloaked pages are blocked or removed, and a removal applies under every save of the image, on every surface, within minutes.
- Out of scope. Ads, shopping catalogues and checkout, messaging, notifications, video pins (see Design YouTube) and the upload mechanics themselves (presigned direct uploads, as in Design Instagram). Models are named, not designed.
Non-functional requirements
- Scale (100 M DAU · 50 k pages/s peak). About 75 feed pages are served for every save, and ~100 B saves point at only ~5 B distinct images. Reads and reuse dominate.
- Home feed latency and availability (p99 < 300 ms · 99.95 % served). The first page of a session runs the whole candidate funnel; later pages read a cached ranked list in ~20 ms. When a source fails the page is thinner, never blank.
- Graph candidates from RAM (walk p99 < 50 ms). This drives the hardest trade-off. A random walk is tens of thousands of dependent memory reads, so the whole graph must sit in one machine’s RAM: ~100 B saves are pruned to ~20 B edges, and the graph scales by replicas, not shards.
- Visual search (p99 < 500 ms over ~10 B vectors). Raw float vectors are ~10 TB, so recall is traded against memory through quantisation, and a re-rank on full vectors wins most of it back.
- Grid lays out before images load (zero layout shift). Every pin in an API response carries its image’s width, height and dominant colour, so the client places all 25 cards and paints coloured tiles before the first image byte arrives.
- Freshness (new pin searchable < 5 min). In followers’ feeds within ~10 minutes, walkable as soon as it is re-saved, in the embedding index after the next daily build. Pins are evergreen, so a day is acceptable on the slow paths.
- Image delivery and durability (CDN hit > 97 % · no lost originals). ~2.4 PB of images a day leaves the edge. Originals are kept with object-storage durability; the fixed sizes can always be regenerated from them.
- Safety (removed pins off every surface < 5 min). A takedown or spam verdict on an image must apply to all of its saves in feeds, search, related pins and caches, not only to the pin someone reported.
Back-of-envelope estimates
- Home feed pages: ~17 k/s avg · ~50 k/s peak. 100 M DAU × 15 pages a day (25 pins each) = 1.5 B/day ÷ 86 400 ≈ 17 k/s; the evening peak runs ~3× that, ~50 k/s. Only the first page of a session runs the candidate funnel, about 1 page in 5, so ~10 k funnels a second at peak.
- Saves: ~230/s avg · ~700/s peak. 20 M saves a day ÷ 86 400 ≈ 230/s, ×3 at peak. About 90 % re-save an image we already hold, so only ~2 M a day bring new bytes. Writes are not the hard part: 75 pages are read for every save.
- Image storage: ~3 PB deduplicated (~60 PB per save). Per canonical image: an original of ~400 KB plus three fixed widths, 236 px 20 KB, 474 px 60 KB and 736 px 120 KB, ≈ 600 KB. 5 B × 600 KB = 3 PB. A copy per save would be 100 B × 600 KB = 60 PB. Growth is 2 M new images × 600 KB ≈ 1.2 TB/day, ~440 TB a year.
- CDN egress: ~2.4 PB/day · ~80 GB/s peak. Grid: 1.5 B pages × 25 pins × 60 KB (the 474 px size) ≈ 2.25 PB; close-ups: 1 B a day × 120 KB ≈ 0.12 PB. Total ~2.4 PB/day ≈ 28 GB/s on average, ~80 GB/s at peak. At a 97 % hit rate the origin still serves ~0.8 GB/s. The head is small: the top 1 % of images (50 M) × 200 KB of sizes is ~10 TB, which fits on every large PoP.
- Pin-board graph in memory: ~800 GB raw · ~185 GB pruned. 100 B save edges stored in both directions (image → boards, board → images) at 4 B per entry = 800 GB, and 7 B nodes do not even fit 32-bit ids. Pruned to ~3 B nodes and 20 B edges: 20 B × 2 × 4 B = 160 GB plus 3 B × 8 B offsets = 24 GB ≈ 185 GB, one 256 GB machine. Every graph server holds all of it.
- Walk compute: ~170 cores at peak. Walks run for ~10 k feed funnels/s plus ~7 k related-pin cache misses/s = 17 k/s. A walk of ~50 k steps at ~200 ns a step (two dependent cache misses) is ~10 ms of one core: 17 k × 10 ms = 170 cores, a handful of 64-core machines per region. The graph tier is small in machines and large in RAM per machine.
- Visual index: ~10 TB raw · ~720 GB quantised. ~5 B whole images plus ~5 B detected objects = 10 B vectors × 256 dims × 4 B = 10 TB of float32, about 80 machines of RAM for one replica. Product quantisation to 64 B plus an 8 B id = 72 B each, 720 GB: 24 shards of 30 GB in RAM, with full vectors on local NVMe for re-ranking.
- Feed cache: ~110 GB. At peak ~10 k sessions start a second, and each ranked list lives 30 minutes: 18 M live lists. 200 ranked entries × ~24 B (pin id, image id, score, source) plus a seen set ≈ 6 KB. 18 M × 6 KB ≈ 110 GB: a small Redis cluster, and all of it can be recomputed.
Components
- Pinterest app (masonry grid · web + mobile): Renders the masonry grid: it places every card from the width, height and dominant colour in the JSON before any image loads, then fills tiles from the CDN at the width its column needs. Saves pins, batches close-ups, clicks and hides as events, and runs visual search on a crop or a camera photo.
- Image CDN (fixed widths · immutable URLs): Serves every image byte, ~2.4 PB a day. URLs carry the canonical image id and a fixed width (236, 474 or 736 px, or the original), so they never change and cache for a year. Deduplication pays off here too: an image saved 100,000 times is one URL and one cache entry, not 100,000. Misses go through a regional shield that coalesces them before object storage.
- Pin service (saves · boards · events): The write API behind the gateway (not drawn): save, re-save, create board, follow, and the engagement events the app batches. A re-save copies the image id from the pin being saved; a save with a new URL or new bytes asks the image service for the canonical image first. Runs the cheap save-time spam checks (account age and velocity, rate limits, link domain reputation) and relays pin events to the bus through an outbox.
- Image service (canonical images · pipeline): Owns the canonical image registry and the media pipeline. Synchronously: normalised source URL, then SHA-256 of the decoded pixels, to an existing image id or a new one. Asynchronously: the three widths, width, height and dominant colour, a 64-bit perceptual hash, object detection, embeddings for the image and each object, and a near-duplicate check that folds resized copies into the canonical image under an alias. One record per distinct image, ~5 B of them.
- Object storage (S3 class · ~3 PB): Originals and the three widths, keyed by canonical image id and width: ~600 KB per image, ~3 PB in total, growing ~1.2 TB a day. Written once and never modified, which is what lets the CDN cache forever. Old originals move to a colder tier; the sizes can always be regenerated from them.
- Pin store (sharded MySQL · key=user_id): Pins (image id, board, link, description, and the image’s width, height and colour copied in at save time), boards and follows, sharded by owner so a board page or a profile is one shard. A pin row is ~300 B, ~30 TB for 100 B pins. A unique key on (board, image) makes a repeated save a no-op.
- Event bus (Kafka · key=image_id): pin.saved, pin.removed and image.removed, plus the app’s engagement events. Graph servers, the search indexer and the offline builds each consume it at their own pace, so a slow consumer never slows a save. Pin events are keyed by image id, so every save of one image is processed in order by one consumer.
- Home feed service (candidates · rank · blend): Builds the home feed and “more like this”. On the first page of a session it gathers ~2,000 candidates from graph walks seeded by the user’s recent pins, followed boards and people, and embedding neighbours, has the ranker score them, then blends for diversity (one card per image, at most two per board, a cap per link domain) and caches ~200 ranked pins for 30 minutes. Later pages are reads of that list, and each request carries what the user engaged with, so the next refill uses it.
- Feed cache (Redis · TTL): Three short-lived key families: each session’s ranked list (~6 KB, 30 minutes, ~110 GB at peak), each board’s newest 50 pin ids for its followers (10 minutes), and each canonical image’s related-pin candidates (~500 ids, 6 hours). Everything in it can be recomputed, so a lost node costs latency, never data.
- Graph walk service (pin-board graph in RAM): Holds the whole pruned bipartite graph of canonical images and boards (~3 B nodes, ~20 B edges, ~185 GB) in RAM on every server, as compressed adjacency arrays in both directions, and answers random-walk queries in ~10 ms of CPU, the approach of Pinterest’s published Pixie system. Sharding would put most steps of a walk on the network, so it scales by replicas. A daily snapshot plus an in-memory overlay of today’s saves from the bus makes a new save walkable within seconds.
- Model serving (embedding ANN · GPU ranker): Two things the feed calls. A nearest-neighbour index of graph-aware image embeddings, in the spirit of Pinterest’s PinSage paper (each image embedded from its own visual and text features and its graph neighbourhood): ~2 B recommendable images compressed to ~144 GB across 6 shards. And a GPU ranker that predicts save, close-up, click-through and hide for ~1,000 candidates, on engagement aggregated across every save of an image. Both are loaded daily from the offline builds.
- Search service (text tiers · query encoders): Text and visual search, ~7 k and ~400 queries a second at peak. Holds the inverted index, one document per canonical image built from every save’s description and board name plus the link’s page title, as a daily main tier and a real-time tier fed from the bus. Embeds text queries and image crops into one shared image space, queries the visual index, fuses lexical and vector candidates, filters removals and ranks.
- Visual index (IVF-PQ · ~10 B vectors): Embeddings of ~5 B whole images and ~5 B detected objects in one image space, which a text encoder is also trained into: 10 TB as float32, ~720 GB as 64-byte PQ codes, held in RAM in 24 shards by image id with full vectors on NVMe for re-ranking. Rebuilt weekly with fresh centroids and codebooks; an HNSW fresh tier holds the last 7 days in full precision, and every query reads both.
- Offline builds (daily · weekly · versioned): Daily: snapshots the pin store into the pin-board graph, prunes it and ships it to the graph servers; recomputes image embeddings and the ranker; aggregates engagement onto canonical images; rebuilds the main text tier; scores spam and quality per image, board, account and link domain. Weekly: rebuilds the visual index. Every artefact is versioned, loaded beside the old one and checked before it replaces it.
User flows
- Save an image from the web. The core write. Most of the work is deciding whether we already hold this image, because 90 % of the time we do, and a save must cost a row, not a copy.
- The user saves an image from a recipe site to their “Weeknight dinners” board. The browser button sends the image URL, the page URL and the alt text, not the bytes; an upload from a phone would first go straight to object storage with a presigned URL, as in Design Instagram. The client generates an idempotency key, so a retried save does not create a second pin.
- The pin service asks the image service for the canonical image behind that URL. First a lookup of the normalised URL (lowercased host, tracking parameters stripped): a popular image is saved from the same address thousands of times, so most web saves end here with no download. On a miss the service fetches the file and looks up a SHA-256 of its decoded pixels, which catches the same picture served from another address or with different metadata.
- For an image never seen before, it stores the original and writes the three fixed widths. Widths 236, 474 and 736 px in WebP, with JPEG copies for old clients, keyed by image id and width: ~600 KB with the original. Width, height and the dominant colour (a small k-means over a 32 px thumbnail that ignores near-white backgrounds) are computed here, because the grid needs them before any image byte is downloaded.
- The pin row is written with the image’s size and colour copied in, and the save returns. Copying width, height and colour into the pin makes hydration one read per pin, and it is safe because a canonical image never changes. The row and its outbox event commit in one transaction on the owner’s shard; a unique key on (board_id, image_id) turns saving the same image to the same board twice into a no-op.
- In the background the image service detects objects, embeds the image and each object, and adds them to the visual index’s fresh tier. A detector proposes boxes with categories (lamp, sofa, shoe); each box and the whole image get a 256-dimension vector from the same visual model. Boxes are stored on the image record, so a close-up shows tappable dots without running the detector again. The fresh tier is HNSW in full precision, so the vectors are searchable seconds after the insert.
- A near-duplicate check on the new vectors folds the image into an existing canonical one if it is the same picture. The nearest vectors are only candidates; the 64-bit perceptual hashes must differ in 6 bits or fewer and the aspect ratios agree within 1 %. That means the same picture at another size or quality; a crop, a watermark or overlaid text stays a separate image linked as similar. The existing canonical image always wins, so its URLs and sizes never change; the new id is kept as an alias so nothing that already saw it breaks, and the duplicate’s bytes are deleted after 30 days.
- The pin event reaches the graph servers, which add the edge to today’s overlay, and the search indexer. Every graph server appends (image, board) to an in-memory overlay that walks read alongside the daily snapshot: 20 M edges a day in both directions is ~320 MB. The search service adds the pin’s words to the image’s document in its real-time tier within about 10 seconds. The save returned long before either.
- Open the home feed. The main read. Candidates come mostly from the graph, not from follows; they are ranked and blended once per session and served in pages from a cache. Every card carries enough metadata to lay out the masonry grid before a single image loads.
- The app asks for the first page of the home feed. The gateway (not drawn) authenticates and forwards. There is no session list for this user in the cache, so this request runs the funnel; the next pages will not.
- The feed service picks query pins from the user’s recent activity and asks the graph for random walks from them. Up to 20 query pins: recent saves weigh more than close-ups, which weigh more than clicks, decaying over weeks. Walks step image → board → image, jump back to a query pin now and then, and count visits; images reached from several query pins are boosted, and the walk stops early once enough images have high counts. ~1,000 images in ~20 ms.
- In parallel it gathers recent pins from followed boards and people, and embedding neighbours. The follow list (capped at the ~200 most engaged boards) comes from the pin store; each board’s newest 50 pin ids come from the cache, read through with a 10-minute TTL, so a board with a million followers costs one store read every 10 minutes. The user’s recent pins are clustered into up to 5 interests, and each cluster centre queries the embedding index for 200 neighbours, which catches images the graph has not connected yet.
- Candidates are merged by canonical image and filtered, and the ranker scores about 1,000. One image saved by three followed people is one candidate with three source tags, which is itself a strong feature. Filters drop images the user saved or hid, removed images (a bitmap of 5 B bits, 625 MB, on every feed host) and low quality scores. A light model trims ~2,000 to ~1,000, and the GPU ranker predicts save, close-up, click-through and hide from engagement summed over every save of each image.
- The blender orders them for diversity and caches about 200 ranked pins for the session. Greedy picks from the top: no two near-identical images on one screen, at most 2 per board and 3 per link domain in any 25, a spread of topics, and followed pins kept between 15 and 30 %. Each image is shown as the followed person’s save if there is one, otherwise as its representative pin (the save with the best description and a working link, chosen offline).
- The first 25 are hydrated in one batched read and returned with width, height and colour. Pin rows already carry the image’s dimensions and colour, so hydration is one multi-get grouped by shard, ~10 ms. The app puts each card in the currently shortest column, sizes it from the aspect ratio and paints its colour at once: the grid is fully laid out before any image arrives, and nothing jumps when they do.
- The app loads each tile from the CDN at the width its column needs. A two-column phone asks for 474 px, desktop for 236 or 474 px. URLs never change, so the head of popular images is nearly always an edge hit; a miss goes to a regional shield and then to object storage. Tiles below the fold load as they approach the viewport.
- Scrolling reads the next 25 from the cached list, and engagement rides along on the request. Page 2 is a cache read, a skip of anything hidden since, and a hydrate: ~20 ms. The request carries the pins the user saved or opened on earlier pages, and when ~50 entries remain the feed service refills the list in the background with those pins as fresh query pins, so the feed follows the session.
- A pin goes viral: more like this. The scale-breaking case. One image is saved 100,000 times in a day and opened millions of times. Because a pin is a save of a canonical image, it stays one record, one URL and one cache entry; the risk moves into the graph, where the image becomes a hub.
- Thousands of people save the same image; each save is a new pin row and one more edge on the same canonical image. Re-saves carry the source pin, so the image service is not called and no bytes move. 100,000 saves a day is about one a second for this image, landing on 100,000 different owners’ shards: nothing is hot. A copy per save would have been 60 GB of duplicate bytes for this one picture.
- Millions open it; every close-up is the same CDN URL at 736 px. All 100,000 pins share one image id, so they share one URL per width and one cache entry per PoP. The first request at each PoP misses to the shield, and the shield coalesces concurrent misses, so object storage sees one read per region however many arrive at once.
- The close-up asks for more like this, and the image’s related-pins candidates are a cache read. Related pins depend on the image, not the viewer, so the unpersonalised candidates (~500 ids) are cached per canonical image for 6 hours: one walk serves millions of opens. The feed service only re-ranks the top 200 with the viewer’s features and drops what they have already saved.
- On a miss, the feed service walks from this one image and adds embedding neighbours. A walk from a single query pin is the related-pins shape: image → the boards it was saved to → the other images on those boards. Embedding neighbours add look-alikes and conceptual matches that share no boards. Only one miss per image per 6 hours reaches the graph, which keeps related-pin walks to ~7 k a second at peak.
- Inside the graph the viral image is a hub, so its degree is capped and its visits are discounted. With 100,000 boards, walks from unrelated starting points would keep landing on it, and it would appear in everyone’s results. The daily build keeps at most 10,000 of a hub’s board edges (the boards whose topics best match the image), the overlay caps new edges per node per day, and scoring divides visits by a power of the image’s degree. Popularity still helps; it cannot crowd out relevance.
- Engagement on all 100,000 pins is summed onto the canonical image for ranking. The ranker reads one save rate and one close-up rate for the image, not 100,000 thin ones, which is why a pin saved by someone with three followers still ranks on the image’s whole history. Aggregation runs hourly: pins are evergreen and their value is measured over weeks, so per-second counters would buy nothing here.
- Visual search, with a slow shard. Search over ~10 B vectors that only fit in RAM once quantised. The query fans out to 24 shards and one of them is slow; the design still returns a good answer on time.
- The user taps the lamp in a living-room pin, or draws a box around it. For a pin, the detector’s boxes were stored at save time, so the app shows tappable dots as soon as the close-up opens. Tapping a stored box sends its id; a drawn box or a camera photo sends the crop.
- The search service embeds the crop into the shared image space. A stored box already has its vector from save time, so that case skips the model entirely. A new crop runs the visual encoder on a GPU in ~15 ms; a camera photo first runs the detector (~30 ms) and searches with the most central object. It must be the same model version as the index, or the distances mean nothing.
- It queries all 24 shards of the main tier and the fresh tier in parallel. Each shard holds ~420 M PQ codes in an IVF of 65,536 lists; the query scans the 64 nearest lists (~400 k codes, ~5 ms with SIMD lookup tables) and re-scores its best 200 against full vectors read from local NVMe. The fresh tier, HNSW over the last 7 days in full precision, returns its own top 200.
- One shard is slow: after its p95 the request is hedged to a replica, and at the deadline the merge goes ahead without it. Shards are by image id, so each holds a random 1/24 of the corpus: answering from 23 shards loses about 4 % of candidates spread evenly, not a whole category. That is the reason the index is not partitioned by category, even though a lamp query only wants lamps. The response is marked partial and the shard’s latency is alarmed on.
- Hits are merged, grouped by canonical image and filtered. Several objects of one image, or an image found in both tiers, collapse to one result with the best distance. Removed and spam images are dropped against the removal bitmap. A hit whose detected category disagrees with the query box’s (a lampshade print on a dress) is demoted rather than dropped.
- The top results are ranked on similarity, quality and engagement and returned with layout metadata. Visual distance leads; image quality, the canonical image’s save rate and a working, non-spam link domain break near-ties, which matters most for products. Results carry width, height and colour like every other grid, and show each image as its representative pin.
- A new pin is found; a spam pin vanishes. The background path. The big indexes are rebuilt daily or weekly, so freshness comes from small real-time tiers merged at query time, and removal comes from a filter applied after the merge rather than from editing the big indexes.
- At 10:00 someone saves a brand-new photo of a brass floor lamp, described as “my reading corner”. The save path of the first flow: a new canonical image, its sizes, a pin row. Nothing in the daily text tier, the weekly visual build, the daily graph or the daily embeddings knows about it yet.
- Within seconds it is in the real-time text tier and the visual fresh tier. The real-time text tier is a small in-memory index of the documents changed since the last daily build, refreshed every ~10 s. The visual fresh tier already has the image’s vectors from the pipeline. Both are tiny next to their main tiers, hours or days of data against billions, so they rebuild their own structures cheaply.
- At 10:04 a user searches “brass floor lamp”; the text index and the query embedding run side by side. Lexical: both text tiers return images whose aggregated words match; each document is built from every save’s description and board name, so a popular image is easy to find under words its first saver never used. Semantic: the text encoder maps the query into the image space, and both visual tiers return images that look like a brass floor lamp. The new pin matches no word of the query and is found by its picture.
- Overnight the builds fold the day into the main tiers and trim the real-time ones. The daily build snapshots the pin store into a new pruned graph and new embeddings and rebuilds the main text tier; the weekly one rebuilds the visual index with fresh centroids and codebooks. Each version is loaded beside the old one, checked (recall against exact search on sampled queries, node and degree counts) and switched by one pointer, and then the real-time tiers drop what the main tiers now cover.
- Meanwhile a burst of day-old accounts saves one image 3,000 times, each with a link to a different new domain. Deduplication is what makes this visible: one canonical image, thousands of saves, thousands of unrelated domains registered last week, from accounts a day old. Save-time checks already slowed these accounts (velocity limits, domain age); the hourly spam model scores the image, its boards, the domains and the accounts together, and the cluster crosses the removal threshold.
- The removal is applied once, to the canonical image, and reaches every surface as an event. The image and its pins are marked removed and image.removed is published. Graph servers tombstone the node so walks step over it, and the search service and feed hosts set its bit in their removal bitmaps, which also filter cached session and related lists at read time. Nothing in the main indexes is edited; the next builds simply leave it out.
Deep dives
A pin is a save of an image
Why not just store each pin with its own copy of the image?
About 100 B pins point at about 5 B distinct images: on average each image is saved 20 times, and the distribution is brutally skewed, with most images saved once and a popular recipe photo saved hundreds of thousands of times. A copy per pin would be ~60 PB instead of ~3 PB, but storage is the smaller cost. 100,000 copies of one picture would also be 100,000 CDN URLs competing for edge cache, 100,000 thin engagement histories instead of one strong one, and 100,000 graph nodes that should have been one.
So the model separates the two things a pin is. The image is content, stored once and identified by what it looks like. The pin is an act: who saved it, to which board, with which words and link. Everything that measures the content (engagement, quality, spam score, embeddings, the graph node) attaches to the canonical image; everything personal stays on the pin. The hard part is deciding when two files are the same image.
- Canonical image by exact hash, near-duplicates folded in by perceptual hash, pins referencing it chosen
- A copy of the image per pin rejected
- Exact-hash deduplication only situational: as the synchronous first step at save time, before the asynchronous near-duplicate check
- Cluster by embedding similarity alone rejected
The answer: Three levels at save time. A normalised source URL lookup answers most web saves without a download; a SHA-256 of the decoded pixels answers exact copies; and an asynchronous near-duplicate check in the first minute takes the nearest neighbours from the visual index and accepts one only if the 64-bit perceptual hashes differ in 6 bits or fewer and the aspect ratios agree within 1 %. A match means the same picture at another size or quality: the new id becomes an alias of the existing canonical image, which keeps its URLs and sizes unchanged, and the few pins created in that minute are re-pointed. A crop, a watermark or overlaid text changes the picture, so those stay separate images linked as similar, and the feed’s blender keeps similar images apart. Pins hold the image id plus the width, height and colour copied in, and live on their owner’s shard; the canonical image record holds the hashes, sizes, detected boxes, quality and spam scores, and the representative pin used when the image is shown without a particular save.
Two different photos happen to have perceptual hashes 5 bits apart. What stops a false merge?
The hash alone never merges. The candidate must also be among the nearest neighbours in the visual embedding space and have the same aspect ratio within 1 %, and very low-detail images (a solid colour, a plain logo), whose hashes collide easily, are excluded from near-duplicate merging altogether. If a false merge slips through, it is reversible: the duplicate’s original bytes are kept for 30 days, so splitting it back is a metadata change. User reports of “this is not my image” are the metric that says whether the thresholds are right.
A user deletes their pin. What happens to the image?
Nothing, unless it was the last reference. The pin row is deleted, the graph edge leaves the overlay when the event arrives and the snapshot at the next build, and the image’s save count drops at the next hourly aggregation. When the last pin goes, the image is marked unreferenced and collected after 30 days by a job that re-checks the count first, because a new save can arrive in between.
A copyright owner asks you to remove an image. It has 40,000 saves.
That is one operation on the canonical image, which is a big advantage of this model: mark it removed, publish image.removed, and every surface drops it through its removal bitmap within a minute, while its handful of CDN URLs are purged. Near-duplicates folded into it go with it automatically. Separate images linked to it as similar, such as a crop, are queued for review rather than removed blindly.
Why hash decoded pixels instead of the file bytes?
The same picture is often served with different metadata, an added colour profile or a stripped EXIF block, which changes the file hash but none of the pixels. Hashing decoded pixels catches those for the price of a decode we do anyway to make the sizes. A recompression changes the pixels slightly, so that case falls to the perceptual hash.
How does a re-save avoid the image service completely?
A re-save names the pin being saved, and that pin already knows its canonical image id, width, height and colour. The pin service copies them into the new row and writes it: no lookup, no fetch, no hash. That is the 90 % case, which is why saves are cheap even though new images are expensive.
Random walks on the pin-board graph
Why recommend from random walks over the save graph instead of only from embeddings, and how do you serve them in 50 ms?
Every save says that a person thought an image belonged with the others on one board. Across ~100 B saves that is the strongest signal this kind of content has: a board called “Mid-century living room” groups images more precisely than any pixel or caption model. The natural query is “images that share boards with the images this user engaged with”, a two-hop neighbourhood, image → boards → images. Done exactly, a popular image on 100,000 boards of ~1,000 pins each touches 100 M paths, far too many for one request.
A random walk with restart estimates that neighbourhood by sampling: step from an image to a random board it is on, then to a random image on that board, count where you land, and every so often jump back to the start. The counts concentrate on the images most strongly connected to the start, and the work is set by the number of steps, not by the size of the graph. The catch is memory: each step is a dependent random read, ~100 ns from local RAM and ~100 µs across the network, so the walk must run where the whole graph lives. Pinterest’s Pixie paper (WWW 2018) describes this design: walks over a pruned pin-board graph held in memory on each server.
- Random walks with restart on a pruned graph held whole in RAM on every server chosen
- Embedding nearest neighbours only situational: alongside walks: as the source for images with few saves, and for conceptual matches the graph has not connected
- Precomputed co-save lists per image (item-item collaborative filtering) situational: for the head of popular images, where a stored related list is effectively a cache of what walks would return
- Walks over a graph sharded across machines or in a graph database rejected
The answer: A bipartite graph of canonical images and boards, built daily from a pin store snapshot and pruned: images saved only once are dropped (a walk that steps onto one can only step back), catch-all boards whose pins span too many topics are dropped, spam and low-quality boards are dropped, and hubs keep at most 10,000 board edges, the most topical ones. That takes ~100 B save edges to ~20 B edges over ~3 B nodes, small enough for 32-bit node ids and compressed adjacency arrays in both directions: ~185 GB, on 256 GB servers that each hold all of it. A query takes up to 20 weighted query pins from the user’s recent activity, splits a budget of ~50 k steps between them by weight, walks with restart, prefers boards in the user’s language and topics, boosts images reached from several query pins, divides visits by a power of the image’s degree, and stops early once enough images have high counts. Today’s saves land in an in-memory overlay from the event bus, so a re-save made a minute ago is walkable. The published Pixie system works this way (walks with restart, biasing by user features, a boost for multi-query hits, early stopping, graph pruning); the thresholds here are my own starting points.
The graph doubles and no longer fits in one machine. Now what?
First prune harder and compress better: tighter degree caps, dropping boards untouched for years, and delta-encoded adjacency lists buy a lot before any architectural change. Next, bigger machines, which is still the cheapest option. If it truly cannot fit, split it into a few graphs by language or region that are each whole, and route a query to the user’s graph, accepting that walks across them are lost. What I would not do is shard one graph across machines, because every step would become a network hop.
A user’s recent saves are all brand-new images with no edges in the graph. Where do the walks start?
From proxies. For each query pin with no graph presence, take its nearest neighbours in the embedding index that do have edges and start the walk from those, weighted by similarity. The overlay often helps too: if the image was re-saved even once today, it already has an edge. And the embedding source contributes candidates directly, so the feed never runs dry for lack of graph signal.
A graph server crashes at the peak. What happens?
Its in-flight walks fail and the feed service retries them on another replica; any replica answers any walk, so there is nothing to rebalance. A replacement loads the current snapshot from blob storage (~185 GB, a few minutes on a fast network) and replays the day’s overlay from the bus offset recorded with the snapshot, then joins the pool. The tier runs N+2 per region, so losing one does not even move latency.
Why not count co-saves exactly instead of sampling?
Exact counts from popular images explode: an image on 100,000 boards of ~1,000 pins each reaches 100 M two-hop paths, and the counts are dominated by giant boards and hub images. A walk with a fixed step budget samples the neighbourhood in proportion to how strongly it is connected, ignores the long tail of weak paths by construction, and combines many query pins in one pass. It is an estimate, but of the right quantity at a fixed cost.
How do you know the walk candidates are any good?
Measure each source on its own. Log which source produced every impression and compare save and hide rates per source and position. Offline, replay: of the pins users saved today, what share were in the walk candidates generated for them yesterday, recall@1000 for the source. Pruning and degree-cap changes ship as experiments, because they change every walk.
Building the home feed
Most of the home feed comes from people the user does not follow. How do you build it, and why not precompute it with fan-out like Instagram?
At ~50 k pages a second at peak, the home feed is the busiest read. It is a different problem from a follow feed: pins are evergreen, so a two-year-old recipe can be the best pin to show today, and most of what a user engages with comes from recommendations, not from the boards they follow. Fan-out on write solves “deliver new posts from people I follow”, which here is one source out of three.
The useful observation is that a session’s feed changes slowly. Candidates from walks and embeddings depend on the user’s history, which moves when they save something, not between page 1 and page 2. So the expensive funnel can run once per session, ~10 k times a second at peak instead of 50 k, pages can be served from its output, and the session’s own engagement can be folded in when the list is refilled.
- Per-session funnel (walks, follows, embeddings), ranked and blended once, served in pages from a 30-minute cache chosen
- Fan-out on write of followed boards into per-user timelines situational: for a separate “following” tab that shows only followed boards in time order
- Recompute the whole funnel on every page rejected
- Precompute each user’s feed nightly rejected
The answer: The first page of a session builds ~2,000 candidates in parallel: ~1,000 from graph walks seeded by up to 20 recent pins, ~800 from followed boards and people (the follow list from the pin store, each board’s newest 50 from the cache), and ~1,000 from the embedding index, with the user’s recent pins clustered into up to 5 interests. They are merged by canonical image, so an image found three ways is one candidate with three tags; filtered for saved, hidden, removed and low-quality images; trimmed to ~1,000 by a light model; and scored by the GPU ranker for save, close-up, click-through and hide. A greedy blender then fills the order: no near-identical images on one screen, at most 2 per board and 3 per link domain in any 25, a spread of topics, and followed pins between 15 and 30 %. The top ~200 are cached for 30 minutes (~6 KB each, ~110 GB at peak); pages are a read plus a hydrate, and when ~50 remain the list is refilled with the pins the user just engaged with as new query pins. New users start from the topics they pick at sign-up: walks seeded from top images in those topics, popular pins for their country and language, and a deliberately wide spread of topics, until a few saves give the walks something personal.
Why “at most 3 per link domain”? That is not a relevance rule.
It is a diversity rule and a spam brake at once. A user who likes one recipe site would otherwise get a feed that is one site, which is worse for them, and a spammer who gets one image to rank well would otherwise get the whole page. A cap per domain costs little relevance and bounds the damage any one domain can do. The same reasoning gives the cap per board.
The user saves a pin on page 2. When does the feed react?
On the next refill: the request carries the save, and the feed service refills in the background with it as a heavily weighted query pin, early if the save is a strong signal. Pages already in the list are not re-ranked, because reshuffling what is just below the user’s thumb is jarring. The next session also starts from it.
The feed cache loses a node at peak. What do users see?
Users whose session lists lived there miss on their next page, and the feed service runs the funnel again, which is the first-page path and works. The risk is the burst of extra funnels; the walk and GPU tiers keep ~30 % headroom at peak, and if they run short the feed service builds a cheaper page (walks and follows, ordered by a light model) rather than failing. A few repeats are possible for those users, because the seen set lived with the list.
Why rank after merging by canonical image instead of ranking pins?
Because the ranker’s best features belong to the image: its save and close-up rates over every save, its quality, its topics. Ranking pins would score three saves of the same image three times and then show the same picture three times. After ranking, the blender picks which save to display: a followed person’s if there is one, since “saved by Ana” is a reason to look, otherwise the representative pin.
How would you know the feed is getting better?
Saves and close-ups per session as primary metrics, because a save is the user saying the pin was worth keeping, with hides and quick bounces as guardrails and 7- and 28-day return rates over longer A/B tests by user. Per-source save rates show which source earns its slots, and the share of followed pins is watched so a model change does not silently starve the follow graph.
Visual search over 10 B vectors
A user draws a box around a lamp in a photo. How do you find similar lamps among billions of images in a few hundred milliseconds, and what does it cost in memory?
The searchable unit is not just the image: a living-room photo holds a lamp, a sofa and a rug, and people search for one of them. Indexing every whole image and every detected object gives ~10 B vectors. At 256 float32 dimensions that is 10 TB, about 80 machines of RAM for a single replica, before any graph links. An exact scan reads all 10 TB per query, which is out of the question.
The query rate is low, a few hundred a second, so the index is sized by memory, not by CPU. That is the shape for product quantisation: keep a 64-byte code per vector in RAM, find candidates with approximate distances, then win back most of the precision by re-scoring a few hundred candidates per shard against full vectors on local NVMe. What remains is how to shard and how to keep new images in it.
- IVF-PQ in RAM, 24 shards by image id, full-vector re-rank from NVMe, plus an HNSW fresh tier chosen
- HNSW over full-precision vectors in RAM rejected
- A disk-based graph index (DiskANN class) on NVMe situational: if the corpus grew several times over and RAM ran out even for PQ codes
- Partition the index by detected category and query only the matching one situational: for a narrow product such as shopping-only search, where categories are clean and a miss is cheap
The answer: One image space for everything: a visual encoder embeds whole images and detected objects into 256 dimensions, and a text encoder is trained into the same space so text search can use the index too. Vectors are product-quantised to 64 bytes and stored with an 8-byte id in an IVF of 65,536 lists per shard; 24 shards by image id hold ~420 M vectors and ~30 GB each in RAM, replicated three times per region for availability rather than for load. A query scans the 64 nearest lists per shard (~400 k codes, ~5 ms with SIMD lookup tables), re-scores the shard’s best 200 against full vectors on NVMe (200 random 1 KB reads, a few ms) and returns them; the search service merges, collapses hits to canonical images and ranks. Recall@100 against exact search on sampled queries is the tuning target, ~95 %, with nprobe and the re-rank depth as the dials. The weekly build retrains centroids and codebooks on a sample and re-encodes everything; the fresh tier, HNSW over the last 7 days (~28 M vectors, ~28 GB in full precision), is read on every query, so a new image is findable seconds after its vectors exist. Pinterest has published papers on its visual search (for example at KDD 2015) with the same overall shape of detecting objects, embedding them and looking up nearest neighbours; the sizes here are mine.
Visual search traffic grows 10×. What do you add?
Replicas, not shards: at a few thousand queries a second each shard still spends only ~5 ms of scan per query, so each shard group gets more copies. NVMe re-rank reads grow linearly too, and a drive serves hundreds of thousands of random reads a second. If cost bites, lower nprobe at peak and cache results for stored boxes on popular pins, since a tap on the lamp in a viral pin is the same query every time.
Why index every detected object instead of one vector per image?
Because people search for the object, not the scene. A whole-image vector of a living room is dominated by the sofa and the walls, so a query for its lamp finds other living rooms. Object vectors double the index, but they are what makes “find this lamp” work, and stored boxes mean a tap on a pin needs no detector or encoder at query time.
You retrain the visual model. What happens to 10 B vectors?
All of them are recomputed, because vectors from two models live in different spaces. The build re-embeds every image and object from the stored originals (a large but embarrassingly parallel GPU batch), builds a complete new index version beside the old one, checks recall on sampled queries, and switches the query encoders and the index together with one version pointer. Stored box vectors on image records are versioned the same way.
How do you keep spam and removed images out of visual results?
Filter after the merge, never by editing the index: every search host holds the removal bitmap of canonical images (5 B bits, 625 MB) and drops hits against it. Quality and spam scores enter the ranking, so a spammy look-alike loses near-ties to a clean one. The next build leaves removed images out entirely, so the filtered share stays small.
What does product quantisation actually cost you in quality?
Distances from 64-byte codes are approximations, so true neighbours are often found but in the wrong order, and some fall just outside the candidate list. Re-scoring the top 200 per shard with full vectors fixes the order, and fetching 200 when we show 50 catches most of the ones PQ ranked too low. Recall@100 against exact search on a held-out sample of queries measures what is left, and nprobe and the re-rank depth are tuned against it.
Images in a masonry grid
Each page shows 25 images of different heights, 50 k pages a second. How does the grid lay out instantly, and how do you serve 2.4 PB of images a day?
A masonry grid places each card in the shortest column, so every card’s position depends on the heights of all the cards before it. If the client has to download an image to learn its height, the grid either waits (a blank screen) or jumps as images arrive, and on a phone with a weak signal they arrive over seconds. The content is deliberately varied too: tall recipe infographics next to wide photos.
The bytes are the other half: ~2.4 PB a day, ~80 GB/s at peak, almost all of it grid tiles at one or two widths. Demand is heavy-headed, and deduplication concentrates the head further, because every save of a popular image is the same URL.
- Fixed widths generated at upload, immutable URLs on a CDN, and width, height and dominant colour in every pin chosen
- Resize on the fly at the edge for any requested width situational: for rare sizes (a new device class, an experiment), with each result cached like any other size
- Let the client measure each image after it loads rejected
- Crop every image to one aspect ratio rejected
The answer: Generate 236, 474 and 736 px widths at upload (WebP, with JPEG copies for old clients), keep the original for zoom and regeneration, and key everything by canonical image id and width so URLs are immutable and cache for a year. Every pin in every API response carries width, height and a dominant colour (k-means over a 32 px thumbnail that ignores near-white backgrounds), so the client assigns cards to columns and paints coloured tiles from the JSON before requesting a single image; very tall images are capped at 1:3 in the grid and shown whole in the close-up. Phones ask for 474 px and desktops for 236 or 474, so the edge holds about two objects per popular image. The CDN runs edge PoPs over regional shields that coalesce concurrent misses, so a viral image costs one origin fetch per region; the top ~1 % of images (~50 M) need only ~10 TB at the edge, the hit rate stays above 97 %, and the origin serves ~0.8 GB/s on average. The format is part of the URL rather than negotiated from the Accept header, so the cache key never fragments.
A pin goes viral in one country at 9 pm. What does the CDN see?
A spike of requests for the same two URLs. The first request at each PoP misses to the regional shield, which coalesces concurrent misses into one origin fetch, so object storage sees one read per region rather than thousands. After that every request is an edge hit; the pin’s popularity actually raises the hit rate. The thing to watch is the shield’s miss queue in the first seconds, not the origin.
Why copy the colour and dimensions into the pin row instead of reading the image record?
So a page of 25 pins hydrates with one batched read of pin rows instead of 25 pin reads plus 25 image reads. The copies can never go stale, because a canonical image never changes: an edit is a new image with a new id. The duplication is ~10 bytes per pin, ~1 TB across 100 B pins.
You add AVIF, which is ~30 % smaller. How do you roll it out?
As new URLs, not as content negotiation: generate AVIF versions of the three widths, starting with the head of popular images where the egress is concentrated, and let clients that decode AVIF request the .avif URL. The long tail can be converted lazily on first request at the shield and cached. The saving is large: ~30 % of the grid’s ~2.25 PB a day.
A photo is removed for legal reasons. How do you get it out of the CDN?
Purge its handful of URLs (three widths and the original, in each format) by key, which takes seconds across the CDN, and mark the image removed so no API response references it again. Because URLs are per canonical image, one purge covers all of its saves; with a copy per pin it would be thousands of URLs.
Fresh pins in, spam pins out
Your graph and indexes are rebuilt daily or weekly. How does a pin saved a minute ago show up in search, and how does a spam pin removed a minute ago disappear everywhere?
The big structures are batch artefacts for good reasons: pruning the graph needs a global view, image embeddings come from a model over the whole graph, and IVF centroids and PQ codebooks are trained on the whole corpus. Rebuilding them takes hours, and editing them in place would make them impossible to verify or roll back. Meanwhile 20 M saves a day arrive continuously, and a spam campaign can post thousands of pins in an hour.
The way out is never to edit the big artefacts at all. Additions go to a small real-time tier that each serving system merges with its big tier at query time; removals go to a filter applied after the merge. Both are cheap because they are small: a day of saves is 20 M edges (~320 MB), a week of new vectors is ~28 M, and the set of removed images fits in a 625 MB bitmap.
- Small real-time tiers merged at query time, removals as a bitmap filter, big tiers rebuilt and swapped chosen
- Insert new items into the main indexes directly situational: for the visual index between weekly builds if a second tier were too costly, since IVF-PQ accepts appends with existing codebooks
- Rebuild more often instead rejected
- Delete removed items from every index and cache rejected
The answer: Each serving system pairs a big rebuilt tier with a small real-time one. The graph: a daily pruned snapshot plus an in-memory overlay of today’s saves from the bus (~20 M edges, ~320 MB), with new edges per node capped so a viral image cannot rebuild its hub during the day. Text search: a daily main tier plus a real-time tier refreshed every ~10 s. Visual search: a weekly IVF-PQ build plus a 7-day HNSW tier. Each query reads both tiers, merges by image id and lets the newer document win. Removals go the other way: image.removed and pin.removed events set bits in a removal bitmap (one bit per canonical image, 625 MB) on every feed, search and graph host, applied after every merge and to every cached list at read time, so nothing is edited and nothing is missed. Spam is decided in layers. At save time, cheap checks: account age and velocity, rate limits and the link domain’s reputation. Hourly, a model that scores images, boards, domains and accounts together, which is where deduplication pays off again: one image saved by 3,000 day-old accounts with 3,000 unrelated new domains is one obvious cluster. Continuously, a crawler checks link destinations for malware, redirect chains and cloaking. Low quality that is not spam (blurry, tiny, a dead link) lowers an image’s quality score and its rank instead of removing it.
The daily graph build fails two days in a row. What degrades?
Little at first: yesterday’s snapshot keeps serving and the overlay keeps growing with every save, so new saves stay walkable, and at ~320 MB a day a few days fit easily. What ages is the pruning, so spam boards found since are filtered rather than pruned. Two failures page someone, because the embeddings and the main text tier come from the same snapshot and are aging too.
A spammer learns your save-time checks and buys aged accounts. What catches them?
The hourly cluster model, because it looks at what accounts do together rather than at one account: many accounts saving the same images to new boards, linking to a small set of domains, in a burst. The crawler catches the destination side: domains that redirect, cloak (show the crawler one page and users another) or serve malware get a bad reputation that applies to every pin linking to them. Aged accounts are expensive, so the attacker runs out of them too.
Why not run the full spam model synchronously on every save?
The strongest signals are collective and only exist once a burst has started: 3,000 saves of one image look like spam, the first one does not. Within a save’s latency budget only cheap, local checks are possible, and they are tuned to slow attackers rather than to decide. Blocking later costs little, because removal reaches every surface within a minute and a new pin gets little distribution in its first hour anyway.
A removal reaches the bitmap, but a user’s cached session list still holds the pin. Do they see it?
No. Cached lists are filtered against the bitmap at read time on every page, so a list cached 20 minutes ago cannot show an image removed a minute ago. That is the reason to filter at read rather than purge caches: there are millions of cached lists, and finding the ones that hold an image would be a search of its own.
How do you measure freshness?
Canary pins: a job saves a synthetic pin every minute with a unique word and a unique image, then probes text search, visual search and the graph overlay until each finds it, recording the delay per surface. The same canaries are then removed and probed until each surface stops returning them. Those two latencies per surface are the dashboards, alerted at the 5-minute target.
The theory behind it
- Recommendation systems: How large recommendation systems are built: candidate generation, ranking and re-ranking, embeddings and two-tower models, features and feature stores, offline and online serving, feedback loops, cold start, and how to evaluate them.
- Approximate nearest neighbour indexes (HNSW, IVF, PQ): How vector databases find similar embeddings fast: why exact search does not scale, HNSW graphs, IVF clustering, product quantisation and other compression, the recall versus latency versus memory trade-off, filtering, updates and deletes, sharding, and choosing parameters.
- Storing and querying graphs: Social graphs and other graph data in system design: adjacency lists in a key-value store, graph databases, friends-of-friends and mutual friends, celebrity nodes, sharding a graph, and what Facebook's TAO does.