Design Pinterest
A visual discovery app: an endless grid of images in columns of different heights, a pin detail that opens over the grid, related pins, and saving to boards. The hard parts are a masonry layout that never jumps, virtualizing a grid where every item has its own height, loading hundreds of images in the right order, and a detail view that is a modal and a real page at the same time.
The question
Design the web app for Pinterest. People land on a home feed: a grid of images in several columns, where each image keeps its own shape, so the columns end at different heights. They scroll for a long time. Tapping a pin opens its detail with a bigger image, a title, a link, and more pins like it below. They can save any pin to one of their boards.
Most visits come from phones, many of them mid-range Android phones, and many people arrive from Google search straight onto a pin page. Walk me through how you would build the grid, the detail view, and saving, and how you would keep it fast.
Real-life examples: Pinterest home feed and pin pages, Unsplash and Dribbble image grids, Google Images results, Etsy and Behance browse pages
Requirements: what to ask before drawing anything
Pinterest looks like a feed with prettier pictures. It isn't quite. A news feed is one column of posts read top to bottom; Pinterest is a wall of images scanned in two dimensions, where the image is the content.
That one difference changes the layout, the loading order, the virtualization, and the accessibility story. A strong answer names it early and spends the interview on it.
Spend the first five minutes on questions whose answers move the design. The table shows the questions this guide asks, the answer it assumes, and what each answer changes.
| Question | Answer we assume | What it changes |
|---|---|---|
| Which screens are in scope? | Home feed, search results, pin detail with related pins, and the save-to-board picker. | Three screens share one grid component, so it must be generic. |
| Do all images have the same shape? | No. Pins are tall, square, or wide. A recipe pin can be 2.5 times taller than it is wide. | We need a masonry layout, and we need each image's size before it loads. |
| Does the API know each image's width and height? | Yes, the server stores them when the pin is created. | We can place every item before a single image byte arrives: no layout shift. |
| How long do people scroll? | Often 10 to 20 minutes; a heavy session reaches 1,000 pins or more. | Unbounded DOM and memory growth. We need virtualization. |
| Which devices? | About 80% of traffic is mobile. We design for a mid-range Android phone on 4G, then scale up to a 1,600 px desktop. | Column count is responsive; performance budgets are set for the slow phone. |
| How do people arrive? | Many land from Google search on a pin page, and the home feed is the start page for logged-in people. | Pin pages need server rendering for SEO and fast first paint; the feed needs a fast first screen too. |
| What happens when I tap a pin? | The detail opens over the grid. The URL changes to the pin's URL. Back closes it and the grid is where I left it. | A modal route: one URL that renders as a modal or as a full page. |
| Can people save without leaving the grid? | Yes, a Save button on hover (desktop) or on the detail (phone), with a board picker. | Optimistic updates and a small, fast picker. |
Functional requirements
- Show a home feed of pins in a masonry grid: equal-width columns, each pin as tall as its image needs.
- Load more pins as the user scrolls, with no end in sight and no "next page" button.
- Search pins by text and show the results in the same grid.
- Open a pin's detail: the large image, title, description, source link, the person who saved it, and a grid of related pins below.
- Give every pin a URL that works when shared, bookmarked, or opened in a new tab.
- Save a pin to one of the user's boards, or create a new board while saving.
Non-functional requirements, with numbers
Each non-functional requirement gets a number, so it can be measured in the field. The numbers below use Google's Core Web Vitals thresholds, measured at the 75th percentile of real visits (the value 75% of visits beat).
- Fast first screen. LCP (Largest Contentful Paint, when the biggest element on screen is drawn) at or under 2.5 seconds on a mid-range phone on 4G. On Pinterest the LCP element is almost always a pin image.
- No jumping. CLS (Cumulative Layout Shift, a score of how much visible content moves unexpectedly) at or under 0.1. In practice we aim for 0, because every pin's box is sized before its image loads.
- Responsive taps. INP (Interaction to Next Paint, the delay from a tap to the next frame) at or under 200 ms, including the tap that opens a pin and the Save tap.
- Smooth scrolling. No long tasks over 50 ms while scrolling, so the grid scrolls at 60 frames per second on a mid-range phone.
- Bounded memory. The DOM holds a few dozen pins at any time, whether the user has scrolled past 50 pins or 2,000.
- Accessible. WCAG 2.2 AA (the Web Content Accessibility Guidelines, level AA): every pin reachable by keyboard in a sensible order, real alt text, a detail view that manages focus, and motion that respects
prefers-reduced-motion. - Shareable. Every pin and search has a URL that renders real content without JavaScript, for search engines and link previews.
Out of scope, said out loud
Name what you are leaving out, so the interviewer can pull something back in if they care. This guide leaves out: creating pins and uploading images (an upload flow is its own problem), the ranking model that picks which pins to show (a backend concern: we consume a ranked list), comments and messaging, ads (they ride on the same grid as a pin with a flag), boards' own pages, and native apps.
Check your understanding · Exercise 1
Which answers from the interviewer would change the design the most? Select all that apply.
What makes this hard
Before picking any technique, list the traits of the product that will drive every later choice. For Pinterest there are five. Each one rules out an approach that works fine for a normal feed.
- It's two-dimensional. Pins sit in 2 to 7 columns, and each column ends at a different height. The next pin doesn't go "below the last one"; it goes under whichever column is shortest. So layout is a calculation we own, not something the browser's normal flow gives us for free.
- Every item has its own height. A wide photo might be 120 px tall in a 236 px column; a recipe infographic might be 600 px. A virtualized list that assumes one row height is useless here, and even "measure each row once" gets complicated when rows live in several columns.
- The image is the content. A news feed post is mostly text with an optional picture. A pin is a picture with an optional caption. A first screen on a phone shows 6 to 10 images at once, a desktop shows 20 to 30, and the user scrolls through hundreds. Image bytes, decode time, and the order of downloads decide how fast the app feels.
- It's endless and long. People scroll for many minutes, then tap a pin, then go back and expect to land exactly where they were. That combines infinite pagination, virtualization, and scroll restoration, three features that each break the other two if built carelessly.
- The detail view lives two lives. Tapped from the grid, the pin opens on top of the grid, which stays alive behind it. Opened from a Google result or a shared link, the same URL is a full page with its own server-rendered HTML. One route, two presentations.
The wireframe below names the parts of the main screen that the rest of the guide designs: the top bar with search, the masonry grid, a pin card with its hover actions, and the pin detail opened as a modal over the grid.
Two more traits shape the trade-offs without being hard on their own. First, most traffic is on phones, so every budget is set for a mid-range Android phone: a CPU four to six times slower than a developer laptop, 4 GB of memory shared with other apps, and a 4G connection with 50 to 100 ms of round-trip time on a good day. Second, the app is visual, so a placeholder that matches the image's main color makes waiting feel shorter in a way a gray box does not.
Think first
The news feed guide already solved infinite scroll and virtualization for one column. Which parts carry over to Pinterest unchanged, and which don't?
Cursor pagination carries over unchanged: the server still hands out a ranked list in pages, and new pins still appear while the user scrolls. The normalized cache and the idea of keeping only a window of items in the DOM carry over too.
What doesn't carry over is the geometry. In one column, an item's top is the sum of the heights above it. In a masonry grid, an item's position depends on which column it landed in, which depends on every item placed before it. Virtualization has to ask "which items overlap the viewport in any column", and scroll restoration has to rebuild columns, not just one offset.
Keep these five traits in view. Each decision chapter that follows starts from one of them: layout from the first two, loading from the third, scrolling from the fourth, and the detail route from the fifth.
The masonry layout: placing pins of different heights
Masonry is the layout where items have equal widths but their own heights, packed into columns so that there are no holes, like bricks laid without lining up rows. The naive way to build it is to let the browser's flow do the work. That breaks in ways worth seeing before we choose.
Try CSS multi-column layout first: column-count: 3 on the container and the pins inside. It looks right on a static page. But CSS columns fill column 1 from top to bottom, then column 2, then column 3. So pins 1, 2, and 3 stack down the left column, and the user's eye, which scans across the top row, sees something like pins 1, 9, and 18 there.
The best-ranked pins hide down the left side. Worse, when page 2 arrives and we append 25 pins, the browser rebalances all columns, and every visible pin jumps to a new spot.
Decision How do we place pins of different heights into columns?
What we pick: Compute positions from known sizes. The layout is the core of the product and every hard feature later (virtualization, restoring scroll, server rendering) needs positions as data, so we own the math. It's about 30 lines of code and runs in well under a millisecond for 1,000 pins. Keep native CSS masonry (Grid Lanes) on the radar as a future simplification for the visual part, once it ships in every major browser.
The shortest-column algorithm
The algorithm is greedy and simple. Keep an array with the current height of each column, all zero at first. For each pin in feed order, find the shortest column (on a tie, the leftmost), put the pin at that column's bottom, and add the pin's height plus the gap to that column. The pin's height is the column width times the image's aspect ratio (height divided by width), plus the height of the text under it.
type Box = { id: string; x: number; y: number; w: number; h: number };
export function layout(pins: { id: string; width: number; height: number; footer: number }[], containerWidth: number, columns: number, gap = 16) {
const colWidth = (containerWidth - gap * (columns - 1)) / columns;
const heights = new Array<number>(columns).fill(0);
const boxes: Box[] = [];
for (const pin of pins) {
let col = 0;
for (let c = 1; c < columns; c++) if (heights[c] < heights[col]) col = c;
const ratio = Math.min(pin.height / pin.width, 2.5); // cap very tall pins, crop the rest
const h = Math.round(colWidth * ratio) + pin.footer;
boxes.push({ id: pin.id, x: col * (colWidth + gap), y: heights[col], w: colWidth, h });
heights[col] += h + gap;
}
return { boxes, totalHeight: Math.max(...heights) };
}
A worked example with 3 columns, a 236 px column width, and no text: pin A is square (236 px tall) and goes to column 1. Pin B is tall, ratio 1.5 (354 px), and goes to column 2, the leftmost empty one. Pin C is wide, ratio 0.5 (118 px), and goes to column 3.
Now the column heights are 252, 370, and 134, including the 16 px gap. Pin D goes to column 3, the shortest, even though it's the fourth pin and "should" start a new row. That is exactly the point: no holes.
Pinterest's open-source design system, Gestalt, documents the same rule for its Masonry component: each item goes into the leftmost of the shortest columns, and the default column width is 236 px. One difference is worth noting. Gestalt measures each item by rendering it off screen first, while this design computes heights from the image sizes in the API, so nothing needs measuring.
Two details make this production ready. The first is the text under the image: its height isn't in the API, and measuring it means rendering it. So we make it predictable. The title is clamped to two lines with -webkit-line-clamp and a fixed line height, and the server says whether a pin has a title, so the footer is 0, 1, or 2 lines tall.
To tell one line from two, we estimate from the character count and correct the estimate with a ResizeObserver only for pins still below the viewport, where a change can't be seen. The second detail is extreme shapes. A 1:4 infographic would fill a whole phone screen, so the ratio is capped at 2.5 and the image is cropped with object-fit: cover; the full image shows on the detail.
Responsive column counts
The column count comes from the container's width, not the device type. On phones, two fluid columns fill the screen: a 390 px wide phone with 8 px gaps and 8 px side padding gets two columns of about 183 px. From tablets up, columns have a fixed width of 236 px with 16 px gaps, and the count is Math.floor((width + 16) / 252). A 1,600 px desktop gets 6 columns, centered.
When the window is resized across a column boundary, we rerun layout for every loaded pin, which is cheap. To keep the user's place, we note which pin is at the top of the viewport before the change and scroll so it stays there after.
Server rendering the first screen
The first screen should arrive as HTML with images already in it, so the LCP image starts downloading while the JavaScript is still on its way. The problem: the server doesn't know the viewport width, and positions depend on it. Three facts make this workable.
First, phones are most of the traffic, and every phone width gives 2 columns. Second, within one column count, every position can be written relative to the column width: a pin's top is the column width times the sum of the ratios above it, plus a fixed number of pixels for footers and gaps. So the server can emit top: calc(var(--col) * 3.42 + 96px), and CSS sets the column width from the real viewport, with no JavaScript, for example --col: calc((100vw - 24px) / 2) on phones.
Third, on desktop the server guesses the column count from a cookie the client wrote on a previous visit. Without the cookie, it falls back to the device class from the Sec-CH-UA-Mobile client hint (sent by Chromium browsers) or from the user agent.
When the guess is wrong (a desktop visitor with no cookie and an unusual width), the client reruns the layout during hydration, the step where the client's JavaScript takes over the server-rendered HTML. That moves pins once, so we limit the damage: the server renders only the first page of 25 pins, and the fix happens before the user has started reading. Field data on CLS tells us how often it happens.
Check your understanding · Exercise 2
Three columns have heights 400, 250, and 250 px. The next pin is 300 px tall. Which statements are true? Select all that apply.
Endless scrolling: cursor pages and a virtualized masonry grid
The feed has no end, so we load it in pages as the user scrolls. The pagination itself works as in the news feed guide and the pagination lesson: the server returns 25 pins and an opaque cursor string (a bookmark only the server can read), and the next request sends that cursor back.
Offsets (?page=7) would break because the ranked feed changes between requests: pins get inserted near the top, and offset pages then repeat or skip pins. With a cursor, the server remembers where this user's list stands. We also drop any pin whose id is already loaded, as a safety net.
What's new in a grid is when to ask for the next page. In one column, "the bottom" is clear. In a masonry grid, the columns end at different heights, and the first hole the user would see is under the shortest column. So the trigger is: when the bottom of the shortest column comes within two viewport heights of the bottom of the screen, fetch the next page.
Only one request is in flight at a time, and it carries an AbortController signal so it can be cancelled if the user switches from the home feed to a search.
The failure path in the diagram matters. On a train, the next-page request fails. We don't retry in a tight loop and we don't show an error over the grid: the user still has 100 pins to look at.
Instead, we keep the cursor, show a small "Couldn't load more. Retry" row under the shortest column, and retry automatically once when the browser fires the online event. Because the cursor didn't move, the retry asks for exactly the same page.
Why the grid needs virtualization
Now the scale problem. After 15 minutes the user has scrolled past 1,000 pins. Each pin card is about 15 DOM nodes (link, image, title, avatar, overlay buttons), so that's 15,000 nodes in the document, and every style recalculation and layout touches more of them.
Images are worse: a 474 by 711 pixel image takes about 1.3 MB of memory once decoded (474 x 711 x 4 bytes per pixel). Browsers may throw away decoded copies of off-screen images, but with 1,000 live img elements we're relying on that. On a 4 GB phone the tab gets slow, the browser may then kill it, and the user loses their place entirely.
Decision How do we keep a 1,000-pin session light?
What we pick: Windowing from the position cache. The masonry layout already gives us every pin's exact box, which is the hard part of virtualizing variable heights, so windowing costs little extra. We still add content-visibility: auto inside each card for the pins that are mounted but off screen in the margin. Find-in-page matters little on an image wall, so its loss is acceptable.
Finding the visible pins quickly
In one column, the visible items are a single range of indices. In a masonry grid they aren't: column 1 might show pins 40 to 44 while column 3 shows pins 38 to 47. So we keep one array per column with that column's boxes in top-to-bottom order.
Within a column, y only grows, so a binary search finds the first box whose bottom is below the window's top, and we walk down until a box starts below the window's bottom. That's a handful of steps per column, even with 5,000 pins. Then we render the union of those pins, sorted by feed index so the DOM order stays the feed order.
The window is the viewport plus an overscan margin: half a screen above and one and a half below, because people mostly scroll down. Recompute it in a requestAnimationFrame callback scheduled from a passive scroll listener, at most once per frame. Pins are positioned with transform: translate(x, y) inside a container whose height is the tallest column, so mounting and unmounting never changes the scroll height. The lab below shows the window moving through a three-column grid.
Coming back to the same place
Restoring scroll has two cases. When the user taps a pin, the detail opens as a modal and the grid stays mounted behind it, so closing it needs no restoration at all. That's one of the strongest reasons for the modal route (the pin detail chapter).
The hard case is a real navigation: the user opens a board page or follows the pin's source link, then presses back. Now the grid must be rebuilt.
- Set
history.scrollRestoration = "manual". The browser's automatic restoration would scroll before our pins exist and land in the wrong spot. - Keep loaded pages in the in-memory query cache, and also write the pin ids, the cursor, and an anchor to
sessionStorage: the id of the pin at the top of the viewport and how many pixels of it were scrolled past. - On back, rebuild the layout from the cached pins at the current width, look up the anchor pin's new
y, and scroll to it. Using a pin instead of a pixel offset survives a different column count, for example after the phone rotated. - If the page was restored from the back/forward cache (the
pageshowevent withpersistedtrue), everything is already intact and we do nothing. Avoidingunloadhandlers keeps the page eligible for that cache.
Check your understanding · Exercise 3
A 3-column grid has 600 pins loaded and the user is in the middle of it. Which statements about the virtualized grid are true? Select all that apply.
Loading hundreds of images in the right order
On Pinterest, image loading is the performance work. The image-optimization and loading-strategies lessons cover the tools; here we decide how to combine them for a wall of images. Start with the two naive versions, because both are common in real code.
Naive version one: every pin is a plain img with the original upload as src. The first screen on desktop starts 25 downloads at once, some of them 2 MB photos, with no hint about which one matters most. The one image that decides LCP waits in line with 24 others.
Naive version two, the over-correction: loading="lazy" on every image. Now the first pins can't start downloading until the browser has laid out the page and seen that they're in view, which delays LCP. web.dev's guide to optimizing LCP warns against exactly this.
Three priority tiers
| Tier | Which pins | How we load them |
|---|---|---|
| LCP candidates | The first 1 or 2 pins in the server HTML: the top of column 1, plus column 2 on phones. | In the server-rendered HTML, fetchpriority="high", never lazy. The browser's preload scanner (a fast pass over the raw HTML that starts downloads early) finds them before any JavaScript runs. |
| Rest of the first screen | Pins whose box overlaps the first viewport. | Eager, default priority, in the server HTML. |
| Below the fold | Every pin mounted in the overscan margin or loaded later. | loading="lazy" and decoding="async". Virtualization already bounds how many exist. |
The server knows which pins are first-screen because it ran the layout for a guessed column count, so it marks them. A link rel="preconnect" to the image CDN's origin in the document head saves the DNS, TCP, and TLS setup, usually one to three round trips, before the first image request. fetchpriority is Baseline across major browsers since late 2024, so it's safe to rely on.
The right size and format from an image CDN
Uploads arrive in any size. An image CDN resizes, re-encodes, and caches variants at the edge, so the client can ask for the size it needs. We use a few fixed widths (236, 474, 736 px, plus the original for zoom) instead of any width, so each variant is requested often enough to stay in the CDN cache.
Each grid img gets a srcset with width descriptors and a sizes attribute that matches the column width: sizes="(max-width: 599px) 50vw, 236px". The browser then usually picks the smallest file that covers the column at the screen's pixel density.
A worked example: on a phone with a 183 px column and a device pixel ratio of 3, the ideal width is 549 pixels, so the browser picks the 736 variant. A 474 variant would look a little soft, but it has well under half the pixels, so its file is usually much smaller. Many image-heavy sites cap phones at about 2x density for this reason; it's a trade-off to measure, not a rule.
For format, the CDN reads the request's Accept header and returns AVIF when the browser lists it, then WebP, then JPEG, with Vary: Accept on the response so caches keep the versions apart. AVIF works in all current major browsers and often beats WebP on bytes for photos, though the gain varies widely by image and encoder settings.
Decision What does a pin show while its image is loading?
What we pick: Dominant color in the grid. It costs nothing at runtime, which matters when 20 pins mount during a fast fling, and it makes the grid feel complete. On the pin detail, the best placeholder is free: the 236 or 474 px grid image is already in the HTTP cache, so we show it scaled up and blurred under the large image while the 736 px version loads.
Zero layout shift, by construction
Because every box is sized by the layout function from the API's width and height, an image arriving never moves anything. Each img also carries width and height attributes, so even outside the grid (in the detail, in related pins) the browser reserves the space from the aspect ratio. The figure compares a grid built without sizes and one built with them.
Last, fast flings. When the user flicks the grid, pins can enter and leave the window in under 200 ms. We render the box and its color immediately but set the image src only once the pin has stayed mounted for one or two frames. Requests for pins that have left the window aren't guaranteed to be cancelled by the browser, so not starting them is the reliable way to avoid wasting bandwidth during a fling.
Check your understanding · Exercise 4
Which image settings are right for this grid? Select all that apply.
The pin detail: a modal that is also a page
Tapping a pin shows its detail. Product wants three things that pull in different directions: the grid stays exactly where it was behind the detail, the URL is the pin's own URL so it can be shared, and pressing back closes the detail. The url-as-state and spa-vs-mpa lessons give the background; here is how the pieces fit.
The naive versions each fail one of the three. A modal held only in component state keeps the grid but leaves the URL at /: sharing sends the friend to the home feed, and pressing back leaves the site. A normal navigation to /pin/123 gives a real URL, but it unmounts the grid, so going back means refetching, re-laying out, and restoring scroll, and on a slow phone the user sees a flash of an empty grid.
Decision How should the pin detail relate to the URL and the grid?
What we pick: The modal route. It's the only option that meets all three product needs, and the cost is mostly in getting the history rules right, which the rest of this chapter spells out. It also removes the hardest scroll restoration case: tap, look, back is now free.
history.back(), so the history stack never grows and the grid was never unmounted.The history rules, one by one
- Open. The card is
a href="/pin/123", so middle-click, Ctrl-click, and "open in new tab" give a full page for free. We intercept only a plain left click or tap: no modifier keys, primary button. Then we push/pin/123with state{ background: "/" }and render the dialog. - Close. The close button, the Esc key, and a click on the backdrop all call
history.back(); for Esc we listen to the dialog'scancelevent and prevent its default, so the router, not the dialog, decides when it closes. Browsers may ignore that veto (the modal-dialog guide explains when), so acloseevent handler also callshistory.back()if the URL still shows the pin, and Android's back gesture, which also firescancel, takes the same path. Pushing/instead would add a third history entry, and back would then reopen the pin. - Back button. The
popstateevent brings the URL back to/, the router sees no pin, and the dialog closes. The grid never moved. - Pin to pin. Tapping a related pin inside the detail pushes
/pin/456with the same background. Back walks back through the pins the user opened, then closes the detail. That matches what people expect from a browser. - Refresh or deep link. No in-memory grid exists, so the route renders the full page, server-rendered. Its close or logo button navigates to the home feed rather than calling
history.back(), which might leave the site. - Scroll lock. While the dialog is open the page behind it must not scroll, so we set
overflow: hiddenon the root element, andscrollbar-gutter: stablekeeps the grid from shifting sideways when the scrollbar disappears.
Focus and the dialog
The detail renders in a native dialog element opened with showModal(). As the modal-dialog guide explains, that gives us a focus trap, an inert background, and Esc handling from the browser. On open, focus moves to the dialog's heading (the pin title, with tabindex="-1"), so a screen reader announces which pin opened.
On close, focus returns to the card that opened it; see the focus-management lesson. The catch in a virtualized grid: that card must still exist. So while the detail is open, the virtualizer keeps the opener pin mounted even if a resize moved it out of the window. If it was removed anyway, focus goes to the nearest mounted pin in feed order.
The detail must also feel instant. When the user taps, we already have the pin's summary in the cache: image URL, size, dominant color, title. So the dialog renders on the same frame with the grid image scaled up as a placeholder, and two requests start in parallel: the full pin (description, source link, saver, board count) and the first page of related pins. On phones the same route presents as a full-screen sheet instead of a centered dialog, but the history rules don't change.
Check your understanding · Exercise 5
A user opened pin A from the grid, then tapped related pin B, then pressed back once. What should happen? Select all that apply.
Home feed, search, and related pins: three feeds, one grid
Three screens show a masonry grid: the home feed, search results, and related pins under the detail. They differ only in where the pins come from. So we build one MasonryFeed component that takes a feed key and a function that fetches a page for a cursor, and everything from the previous chapters (layout, virtualization, image tiers, load-more) lives inside it once.
Each feed is a separate entry in the query cache, keyed by what defines it: ["feed", "home"], ["search", query, filters], and ["related", pinId]. Each entry holds the list of pages, each page an array of pin ids plus the next cursor. The pins themselves live once in a normalized store keyed by id. If the same pin shows in the home feed and in related pins, saving it in one place updates both, because both read the same record.
Search
The search box suggests queries as the user types, debounced by about 150 ms, with each request cancelled by AbortController when the next keystroke arrives; the autocomplete guide covers this in depth. Submitting a query navigates to /search/pins?q=pasta+salad. The query lives in the URL, so results are shareable, server-renderable for the first page, and the back button returns to the previous query. Because the previous query's pages are still in the cache, going back renders them instantly and restores the scroll anchor stored for that history entry.
Search results change the first-screen story slightly. On a fresh visit to a search URL, the server renders the first page just like the home feed, with the same LCP rules. On a search made inside the app, there's no server HTML, so we keep the old results visible but dimmed until the new first page arrives, and then swap in one step. An empty grid with spinners between two searches would look slower than it is.
Related pins under the detail
Related pins are a full, infinite masonry grid inside the detail. Two details differ from the home feed. First, the detail is a dialog with its own scroll container, so the related grid's virtualizer measures that element, not window; the virtualizer takes the scroll element as a parameter for exactly this reason. Second, its column count comes from the dialog's width, which is narrower than the page.
What we pick: In parallel on open, so the slow recommendation query never delays the pin itself. Images for related pins still follow the lazy tier, so the wasted cost when the user closes quickly is one small JSON response. For the server-rendered full page, the server streams the pin first and the related grid after it, which gives the same effect in one response.
Think first
A user opens 40 pins in one session, each with a related feed of several pages. What happens to memory, and what should the cache do?
Each related feed holds pin records and page lists. Images are the bigger cost, but virtualization already unmounts them. The data alone might be a few hundred KB per feed, which adds up over 40 pins.
Give related feeds a short garbage-collection time: drop a related feed a minute or two after its detail closes, unless it's in the current history trail. Keep the home feed and the current search much longer, since back navigation to them is common. Pin records not referenced by any cached feed can then be removed from the normalized store.
The home feed adds one rule of its own: freshness. If the user returns to the tab after an hour, the ranked feed on the server has moved on. Reflowing the grid under the user's eyes would be jarring, so we fetch a fresh first page in the background and show a small "New ideas" button at the top. Tapping it swaps the feed and scrolls to the top.
Check your understanding · Exercise 6
Which design choices follow from "three feeds, one grid"? Select all that apply.
Saving a pin to a board, instantly
Saving is the action that matters most to the business: it's how people build boards and how the ranking learns what they like. On desktop, hovering a pin shows a board name and a red Save button; on phones the Save button sits on the detail. One tap saves to the board shown (the last one the user used); tapping the board name opens a board picker: a searchable list of the user's boards, most recent first, with "Create board" at the bottom.
The naive version waits for the server: tap Save, show a spinner on the button, and turn it into "Saved" when the response arrives. On a phone with a 300 ms round trip on a busy 4G cell, the button sits on a spinner long enough that people tap it again. The second tap either errors or saves twice.
Saving almost never fails, so making every user wait for the rare failure is a bad trade. The optimistic-updates lesson covers the general pattern.
Decision When does the Save button show the pin as saved?
What we pick: Optimistic, send immediately. Saves succeed nearly always, people expect them to stick even if they close the tab right after, and an undo is cheap as a DELETE. The rollback path must exist and be tested, because when it fires, silently losing a save is the worst outcome.
Idempotency-Key, and only when it gives up does it roll back and tell the user.The mechanics
- Snapshot, then apply. Before changing anything, keep the pin's previous save state. Apply the new state to the normalized pin record, so every grid showing that pin updates at once.
- Idempotency key. Each save gets a random id sent as an
Idempotency-Keyheader. If the network drops after the server saved but before we got the reply, our retry carries the same key and the server returns the first result instead of saving twice. - Rapid taps. While a save for this pin is in flight, a second tap on the same button is ignored. A tap on Undo while the save is still in flight is queued and sent after it, so the server sees save then delete in order.
- Failure. Roll back to the snapshot, and show a toast in an
aria-live="polite"region: "Couldn't save. Try again" with a Retry button. Retrying reuses the same key. - Not signed in. Save opens the sign-up dialog and stores the intent (pin id and board) in
sessionStorage. After sign-in, the app completes the save and shows the toast, so the user's intent survives the detour.
The board picker needs its own care. A heavy user can have 300 boards, so the list is filtered on the client as the user types (no request per keystroke), with the five most recent boards pinned at the top. The list itself comes from one request made the first time the picker opens, cached for the session, and prefetched when the user first hovers any Save button on desktop.
Creating a board from the picker is optimistic too: the new board appears with a temporary id, the save is queued behind the create request, and the temporary id is replaced by the server's id when the create succeeds.
Check your understanding · Exercise 7
A user taps Save on a pin; the request times out, and our client retries. Which statements are true? Select all that apply.
Architecture: the pieces and where their boundaries are
With the hard decisions made, the architecture mostly falls out of them. The app is server-rendered for the first view of any URL, then runs as a single-page app: the server streams HTML for the first page of pins, the browser hydrates it, and every later navigation (opening a pin, searching, going back) happens on the client. Here are the parts and what each one owns.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Router | Maps URLs to screens; renders a pin URL as a modal over a background or as a full page; owns history rules. | The modal route logic must live in one place, or close and back disagree. |
MasonryFeed | One grid for every feed: runs the layout, the virtualizer, and load-more for a feed key. | Three screens share it, so the hard code exists once. |
| Layout engine | A pure function from pin sizes, width, and column count to boxes. | Pure, so the server and the client compute the same positions, and it's easy to test. |
| Virtualizer | Picks which boxes overlap the window of a given scroll element. | Takes the scroll element as input, so it works in the page and inside the dialog. |
PinCard and PinImage | Draw one pin: the dominant-color box, the img with srcset, the title, hover actions. | Owns the image priority tier it is given, nothing else. |
PinDetail | The dialog or the full page: large image, metadata, Save, related MasonryFeed. | One component, two presentations chosen by the router. |
| Query cache | Feed pages by feed key, pin details, the board list; fetching, dedupe, garbage collection. | Server state with its own lifetimes, kept apart from UI state. |
| Pin store | Normalized pin records by id, including the viewer's save state. | One record per pin, so every grid agrees after a save. |
| Save queue | Optimistic saves, idempotency keys, ordering, retries, rollback. | Write logic is subtle and must not be duplicated across buttons. |
| API client | fetch with auth, AbortController, timeouts, error mapping. | One place for network policy. |
| Impression logger | Records which pins were actually seen and batches them to the server. | Feeds the ranking without slowing scrolling. |
Outside the browser there are three services that matter to us. The SSR server (often a backend-for-frontend, or BFF: a server owned by the frontend team that shapes data for one client) calls the internal APIs, runs the same layout function for the guessed column count, and streams HTML. The API serves feeds, pins, boards, and saves as JSON.
The image CDN serves resized, re-encoded images from edge caches near the user; it's where most bytes come from, and the cdn-caching lesson covers how its caching works. HTML for logged-in feeds is personal, so it's never cached at the edge. Images are the same for everyone, so they're cached for a long time under URLs that change when the image changes.
The impression logger deserves a sentence because interviewers ask about it. Ranking needs to know which pins a user actually saw, not just which were loaded. An IntersectionObserver with a threshold of 0.5 marks a pin as seen after it has been at least half visible for about one second. The ids are batched and sent every few seconds, and when the page is hidden (visibilitychange) the last batch goes out with navigator.sendBeacon, which doesn't block the page.
The data flow diagram follows one page of the home feed from the server to pixels, and a save back the other way. Note what crosses each arrow: HTML with JSON for the first page, JSON for later pages, image bytes from the CDN only, and small JSON writes for saves.
Check your understanding · Exercise 8
Why is the layout engine a pure function shared by the server and the client? Select all that apply.
Data model and API
The client keeps four kinds of data, and each lives where its lifetime fits. Pins and boards are server data, held in the normalized store and the query cache. The current screen, the open pin, and the search query live in the URL.
Scroll anchors live in sessionStorage, one per history entry. Hover state, the picker's filter text, and the toast live in component state and die with the component.
type PinSummary = {
id: string;
image: { width: number; height: number; color: string; urls: Record<"236" | "474" | "736", string> };
title?: string; // clamped to 2 lines in the grid
saved?: { boardId: string; boardName: string } | null; // the viewer's save
};
type PinDetail = PinSummary & {
description?: string;
sourceUrl?: string;
savedBy: { id: string; name: string; avatarUrl: string };
saveCount: number;
};
type FeedKey = ["feed", "home"] | ["search", string, string] | ["related", string];
type FeedPage = { pinIds: string[]; nextCursor: string | null };
type Feed = { key: FeedKey; pages: FeedPage[]; status: "idle" | "loading" | "error" };
type Board = { id: string; name: string; coverUrl?: string; lastUsedAt: string };
type ScrollAnchor = { feedKey: FeedKey; pinId: string; offset: number };
type Box = { id: string; x: number; y: number; w: number; h: number }; // derived, never stored
Note what is not stored: boxes. They are derived from pins, width, and column count, recomputed whenever any of those change, and held only in the grid's memory. Storing them would mean invalidating them on every resize. The dominant color and the exact width and height are the fields that make the whole grid design possible, which is worth saying out loud in the interview.
The API
A REST-style JSON API fits: a few resource types, simple reads, and pages that cache well per cursor. GraphQL would also work, and teams with many screen variants like it for picking fields, but nothing here needs it. All list endpoints share one page shape, so MasonryFeed handles them the same way.
GET /api/feeds/home?cursor=eyJyIjo0Mn0&limit=25
GET /api/search/pins?q=pasta+salad&cursor=...&limit=25
GET /api/pins/123/related?cursor=...&limit=25
GET /api/pins/123
GET /api/me/boards
200 OK
{
"items": [
{ "id": "123", "title": "Lemon pasta salad",
"image": { "width": 736, "height": 1104, "color": "#d9c48a",
"urls": { "236": "https://img.example/236/ab.jpg", "474": "...", "736": "..." } },
"saved": null }
],
"nextCursor": "eyJyIjo2N30",
"hasMore": true
}
POST /api/pins/123/saves
Idempotency-Key: 6f1c9a52-...
{ "boardId": "b42" }
201 Created { "boardId": "b42", "boardName": "Recipes" }
DELETE /api/pins/123/saves/b42
204 No Content
Errors: { "error": { "code": "BOARD_NOT_FOUND", "message": "...", "retryable": false } }
Three details in these shapes do real work. The image urls are built by the server, so the client never constructs CDN URLs and the CDN can change its scheme without a client release. nextCursor is null at the end of a finite feed such as a narrow search, which is how the grid knows to show "That's all" instead of a spinner. And errors carry a retryable flag, so the save queue knows whether to retry a 503 or roll back at once on a missing board.
Check your understanding · Exercise 9
Which fields must the feed API return for the grid to render without layout shift and with good placeholders? Select all that apply.
Accessibility and performance on a mid-range phone
A masonry grid is a visual layout, and the accessibility problems come from that. A sighted user scans across: top of column 1, top of column 2, top of column 3. Keyboard and screen reader users move through the DOM order.
If those two orders disagree badly, a keyboard user's focus jumps around the screen. WCAG's Focus Order rule (2.4.3) asks that focus moves in an order that keeps meaning and use intact, and Meaningful Sequence (1.3.2) asks that, where order affects meaning, the correct reading order can be determined from the code.
Our layout choice already helps. The DOM order is the feed order, and greedy placement puts pin 1, 2, 3 across the first row and keeps later pins roughly in rows, so tabbing moves left to right and gently down. Compare the flex-columns layout, where Tab would go down all of column 1 (hundreds of pins, forever, in an infinite feed) before ever reaching column 2. The figure shows both.
- Structure. The grid uses the ARIA feed pattern: a container with
role="feed"and each pin anarticlewitharia-posinset(its index) andaria-setsize(-1because the total is unknown). The container setsaria-busy="true"while a page is being added and back tofalsewhen it's done. Screen readers can then move pin by pin and know where they are. - Names. Each pin's link has the title as its name, and the image has real alt text: the creator's description when there is one, otherwise a generated description, never the file name. If neither exists, the title becomes the alt text, so no pin is ever an unnamed link.
- Fewer tab stops, and feed keys. Each pin is one link plus one Save button, and the hover overlay appears on
focus-withinas well as hover, so keyboard users get the same actions. The feed pattern's keys, which we implement ourselves, let people skip whole pins: Page Down and Page Up move focus to the next or previous pin, and Control + End jumps to the first focusable element after the grid. A skip link at the top does the same for people who don't know those keys, because an infinite grid otherwise traps Tab forever. - Focus survives virtualization. The pin holding focus is never unmounted, even when it scrolls out of the window; otherwise focus would fall to the
bodyand the user would be lost. The same rule keeps the detail's opener mounted. - Motion. Image fade-ins and the detail's zoom animation turn off under
prefers-reduced-motion: reduce.
Performance budgets
Budgets make performance a requirement that a build can fail, instead of a wish. These are our targets for a mid-range Android phone on 4G, tracked from real users with the web-vitals library and enforced in CI with lab runs; see the core-web-vitals lesson for how each metric works. The numbers are illustrative targets for this design, not figures Pinterest has published.
| Budget | Target | What keeps it there |
|---|---|---|
| LCP, 75th percentile | 2.5 s or less | Server HTML with the first images, fetchpriority="high", preconnect to the image CDN, right-sized AVIF. |
| CLS, 75th percentile | 0.05 or less, stricter than the 0.1 threshold for good | Boxes sized from API dimensions; layout recomputed once at most on hydration. |
| INP, 75th percentile | 200 ms or less | Optimistic saves, the detail opening from cached data, no heavy work in tap handlers. |
| JavaScript on first load | About 150 KB compressed | The detail, board picker, and search suggestions are split into chunks loaded on first use or idle prefetch. |
| First-screen image bytes on a phone | About 400 KB | Fixed widths, AVIF or WebP, phone density capped near 2x. |
| DOM size | Under 1,500 nodes | Virtualized grid with about 20 to 40 mounted pins. |
| Scroll frames | No long tasks over 50 ms while scrolling | One window update per frame, memoized cards, transforms instead of layout properties. |
Three rendering habits matter on slow phones. Only the pins entering or leaving the window should render on scroll, so each card is memoized on its pin record and its box, and the window update compares old and new id sets. Positions are applied with transform, which the compositor handles without relayout.
Third, work that isn't urgent, such as sending impressions or prefetching the next page's JSON, waits for idle time with requestIdleCallback where it's available, or a short setTimeout elsewhere. When something does go wrong in the field, the Long Animation Frames API (in Chromium) shows which scripts made a frame slow.
Check your understanding · Exercise 10
Which accessibility choices fit a virtualized masonry grid? Select all that apply.
Running it in the interview
This guide holds more than fits in 45 minutes. The skill is choosing what to say and in what order, so the interviewer sees the hard parts early and can steer you deeper where they care. A plan that works:
| Minutes | What you do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope questions and requirements with numbers. | Ask about image sizes in the API, session length, devices, and entry from search. Say the one-line summary. |
| 5 to 10 | Wireframe and architecture. | Draw the grid with the detail over it, then the boxes: router, MasonryFeed, caches, API, image CDN. |
| 10 to 20 | Masonry layout and virtualization. | Shortest-column placement from known sizes; per-column binary search; load-more on the shortest column. |
| 20 to 28 | Images. | Three priority tiers, srcset with fixed widths, AVIF, dominant-color placeholders, zero CLS. |
| 28 to 36 | Pin detail as a modal route, then saving. | History rules and focus; optimistic save with idempotency key and rollback. |
| 36 to 42 | Data model, API, accessibility, budgets. | Feeds hold ids, pins live once; the feed pattern; the budget table. |
| 42 to 45 | Wrap up. | Name what you'd measure first and what you left out. |
Say the reasons, not just the choices. "Absolute positions from known sizes" is a choice; "because every later feature, virtualization, restoration, server rendering, needs positions as data, and the API gives us sizes so nothing ever jumps" is a senior answer. When you pick something, name what you gave up: "we lose find-in-page for unmounted pins, which matters little on an image wall."
Follow-up questions to expect
Think first
"What if the API can't give you image sizes?"
First push back: the server can read sizes once at upload time, which is far cheaper than every client discovering them. If we truly can't, place a pin only once its size is known. The browser knows an image's naturalWidth and naturalHeight as soon as it has decoded the file's header, usually long before the whole file has arrived, and they read as 0 until then. So we start the image request, poll those properties (or wait for the load event, which is simpler but slower), and add pins to columns in feed order as their sizes arrive, holding later pins until earlier ones are placed so positions never change. Pins wait off screen, so the visible grid never jumps, at the cost of a slower first screen.
Think first
"The user rotates their tablet from 4 columns to 5. What happens?"
We note the anchor pin at the top of the viewport, rerun the layout for all loaded pins with 5 columns (well under a millisecond for a thousand pins), rebuild the per-column arrays, and scroll so the anchor pin is at the same spot. Every pin moves, but the one the user was looking at stays put. The virtualizer then mounts the new window, and image sizes stay valid because from tablets up the column width stays 236 px.
Think first
"How would you add ads to the grid?"
Ads are items in the same feed response, marked with a type and their own size, so the layout treats them like pins. The differences are in loading and measurement: an ad's creative may come from a third party, so it renders in a sandboxed frame inside a box of fixed size to protect CLS, and viewability is tracked with the same impression logger. The ranking server decides their positions, never the client.
- Common mistake: CSS columns. It looks right in the demo and breaks ranking, appending, and virtualization. Mention why you rejected it.
- Common mistake: lazy-loading everything. The LCP image must be eager and high priority.
- Common mistake: a modal with no URL. Sharing, back, and search traffic all break.
- Common mistake: forgetting the slow phone. Set budgets for the mid-range Android phone, not your laptop.
Summary
- Ask for image sizes in the API first: with width, height, and a dominant color per pin, the whole grid can be placed and painted before a single image arrives, so layout shift is zero by construction.
- Place pins with the greedy shortest-column algorithm in a pure function shared by server and client. Appending never moves placed pins, and positions become data.
- Virtualize with the position cache: per-column arrays, a binary search per column, and a window of 20 to 40 mounted pins. Trigger the next cursor page from the shortest column.
- Load images in tiers: high priority for the LCP pin in server HTML, eager for the first screen, lazy below. Use a few fixed widths with
srcsetandsizes, AVIF from an image CDN, and dominant-color placeholders. - Make the pin detail a modal route: a real link, a pushed URL with a background,
history.back()to close, and a full server-rendered page on deep links and refresh. - Save optimistically with an idempotency key, a shared normalized pin record, and a tested rollback.
- Keep DOM order equal to feed order, use the ARIA feed pattern, never unmount the focused pin, and hold budgets set for a mid-range phone.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Optimize Largest Contentful Paint (web.dev) Why the LCP image must be discoverable in HTML and not lazy-loaded.
- Optimize resource loading with the Fetch Priority API (web.dev) How
fetchprioritychanges the order of image downloads. - Browser-level image lazy loading (web.dev) How
loading="lazy"decides when to start a download. - Responsive images (MDN)
srcsetwith width descriptors and thesizesattribute. - Cumulative Layout Shift (web.dev) What counts as a shift and how the score is computed.
- content-visibility (MDN) Skipping rendering work for off-screen content.
- Feed pattern (WAI-ARIA Authoring Practices Guide) Roles and keyboard rules for an infinite list of articles.
- History: scrollRestoration property (MDN) Turning off the browser's automatic scroll restoration.
- Back/forward cache (web.dev) Keeping pages eligible for instant back navigation.
- Masonry (Gestalt, Pinterest's design system) Pinterest's own masonry component: shortest-column placement, virtualization, and a scroll container prop.
- Introducing CSS Grid Lanes (WebKit) The native CSS masonry syntax,
display: grid-lanes. - Understanding Focus Order (WCAG 2.2) Why DOM order must stay close to visual order in a positioned layout.
Concepts behind it
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Loading strategiesDeciding what the browser loads first, what it loads later, and what it loads only when needed, so the pixels that matter show up fast.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.