cf.completefrontend
My progress
SYSTEM DESIGN · #82

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.

QuestionAnswer we assumeWhat 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Wireframe of Pinterest on the web, in two windows. Left, the home feed: a search bar on top and a masonry grid of nine pins in three columns of different heights; pin 1 is hovered and shows its Save button. Right, the same page with a pin open as a modal: the grid is dimmed behind, and a dialog shows a large image, the title, a Save button with the board name, the source link, and a row of related pins below.home feedsearchpin 1pin 2pin 3pin 4pin 5pin 6pin 7pin 8pin 9pin open as modalimageSavetitlesource linkrelated pinspin 1 hovered: Save showsgrid dimmed behind the dialog
One grid component draws the home feed, search results, and related pins. The detail opens over the grid, which stays mounted and in place behind it.

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?

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?

CSS multi-column layout

Set column-count on the container; give each pin break-inside: avoid.

The browser fills columns top to bottom and balances their heights.

Pros

  • Zero JavaScript, works in every browser.
  • Reading order in the DOM is the feed order.

Cons

  • Visual order runs down each column, so ranking is lost across the top row.
  • Appending items rebalances every column: everything visible moves.
  • No positions we can compute, so virtualization is very hard.

One flex column per grid column

Render N column containers side by side, each a vertical flex box.

Assign each pin to a column in JavaScript, usually the shortest so far.

Pros

  • Appending is stable: new pins go to the bottom of a column.
  • Simple CSS, no absolute positioning.

Cons

  • The DOM order is column 1 entirely, then column 2, so keyboard and screen reader order jump far from the visual order.
  • Changing column count means moving nodes between containers.
  • Virtualizing N independent lists is awkward.

Native CSS masonry (Grid Lanes)

Set display: grid-lanes, the syntax the CSS Working Group settled on after years of debate, and let the browser pack the items.

Items flow in DOM order, each into roughly the shortest track.

Pros

  • The browser does the math, including on resize.
  • DOM order stays the feed order.

Cons

  • Not everywhere yet: as of late 2026, Safari 26.4 and later ship it, while Chrome, Edge, and Firefox have it only behind flags, so it can't be the main path.
  • We still can't ask the browser for positions ahead of time, which virtualization needs.

Computed positions from known sizes

The API sends each image's width and height.

A pure function places each pin in the shortest column and returns x, y, height for each.

Pins are absolutely positioned with transform inside a container of known total height.

Pros

  • Exact positions before any image loads: zero layout shift.
  • DOM order equals feed order; visual order is close to it.
  • Positions are data, so virtualization and scroll restoration become lookups.

Cons

  • We own the layout code, resize handling, and the text below the image.
  • Needs reliable sizes from the server.

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.

ts
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) };
}
Placing pins: a pure function from sizes to positions.

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.

ColumnsPlacement
9 pins in 3 columns, placed by shortest column. Column heights: 396 px, 548 px, 632 px. The difference between the tallest and shortest column is 236 px.123456789column ends: 396, 548, 632 pxspread: 236 pxnext pin goes to the shortest
Nine pins in three columns, each placed under the shortest column. Add pins or change the first pin's height.

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.

Sequence diagram for loading more pins, between the User, MasonryFeed, the Query cache, and the API. The user scrolls; MasonryFeed sees the shortest column near the viewport and asks the Query cache to fetch the next page with cursor c3. The cache sends GET with cursor c3; the API replies with 25 pins and cursor c4. The cache appends the ids, and MasonryFeed places the new pins. Later, offline: the cache sends GET with cursor c4, the API call fails with a network error, and MasonryFeed shows a Retry row while the cursor stays c4.UserMasonryFeedQuery cacheAPIscrollsfetchNext(c3)GET ?cursor=c325 pins, c4ids appendedpins placedlater, offlineGET ?cursor=c4network errorRetry row
The trigger is the shortest column, not the last pin. On failure the cursor doesn't move, so a retry asks for exactly the same page, and the pins already loaded stay usable.

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?

Render everything

Append every page to the DOM and keep it there.

Pros

  • Nothing to build; find-in-page and anchors just work.

Cons

  • DOM size and memory grow without limit.
  • Scrolling and resizing slow down as the session goes on, on exactly the phones we care about.

content-visibility: auto on each pin

The browser skips rendering work for pins far off screen.

Each pin declares its size with contain-intrinsic-size, which we know.

Pros

  • One line of CSS; Baseline (supported in all major browsers) since September 2024.
  • Off-screen pins cost little rendering time.

Cons

  • The DOM nodes and img elements still exist, so memory still grows.
  • Doesn't help JavaScript work that walks the tree, such as React reconciling a huge list.

Windowing from the position cache

Keep every pin's box (x, y, w, h) from the layout function.

On scroll, find the boxes that overlap the viewport plus a margin, and render only those.

The container keeps the full height, so the scrollbar is honest.

Pros

  • The DOM holds about 20 to 40 pins whether 50 or 5,000 are loaded.
  • Unmounted pins release their img elements and decoded memory.
  • Positions are exact, so nothing jumps as pins mount and unmount.

Cons

  • Find-in-page can't see unmounted pins.
  • Needs care for focus and screen readers (covered in the accessibility chapter).
  • More code than CSS alone.

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.

Overscan
A three-column grid of 45 pins, 5418 px tall, scrolled to 1400 px with a 700 px viewport. 10 pins are visible and 21 are mounted with overscan half up, 1.5 down; the other 24 are not in the DOM. Visible pins by column: col 1: pins 14 to 20; col 2: pins 11 to 19; col 3: pins 13 to 21.loaded: 45 pinsmounted: 21 pinsDOM: about 315 nodescol 1: pins 14 to 20col 2: pins 11 to 19col 3: pins 13 to 21dashed: the viewporthatched: mounted areafaded: not in the DOM
Mounted: 21 of 45. Load more pins and the mounted count barely changes. The visible pins are not one range of indices: each column is at its own place in the feed.

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 pageshow event with persisted true), everything is already intact and we do nothing. Avoiding unload handlers 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

TierWhich pinsHow we load them
LCP candidatesThe 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 screenPins whose box overlaps the first viewport.Eager, default priority, in the server HTML.
Below the foldEvery 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.

Timeline of image downloads on a phone over 4 seconds, with illustrative timings. Naive: the LCP pin and 24 other images all start at 0.4 seconds at the same priority and share the bandwidth, so the LCP pin finishes at about 3.4 seconds, past the 2.5 second line for good LCP. Tiered: the LCP pin is in the server HTML with high priority and finishes at about 1.5 seconds; the rest of the first screen finishes by about 2.2 seconds; below-the-fold images load lazily after 2 seconds.naiveLCP pin24 otherstieredLCP pinfirst screenbelow fold, lazygood LCP: 2.5 s0 s1 s2 s3 s4 s
Same bytes, different order. Giving the LCP pin the bandwidth first moves LCP well under 2.5 seconds without making the first screen as a whole any slower. Timings are illustrative.

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?

Empty gray box

The box has its final size and a neutral background.

Pros

  • Free: no extra data, no extra work.

Cons

  • A wall of identical gray boxes looks broken on a slow connection.
  • Each image pops in with a hard change of color.

Dominant color

When the pin is created, the server computes the image's main color and stores it as a hex string.

The box's background is that color until the image paints.

Pros

  • 7 bytes per pin and zero client work.
  • The grid already looks like the images that are coming; the swap is gentle.

Cons

  • No shapes, only color.
  • A busy image with many colors gets a muddy average.

Blur hash string

The server stores a 20 to 30 character code (BlurHash or ThumbHash) per image.

The client decodes it into a tiny blurred picture and scales it up.

Pros

  • Shows rough shapes and colors.
  • Small payload.

Cons

  • Decoding runs JavaScript on the main thread for every pin as it mounts, which competes with scrolling on slow phones.
  • Needs a decoder library in the bundle.

Tiny inline image

Embed a 20 px wide blurred JPEG or WebP as a data URL in the API response or HTML.

Pros

  • The browser decodes it natively, off the main thread for the most part.

Cons

  • Hundreds of bytes per pin; 25 pins per page add several KB of payload.
  • Bloats server HTML for the first screen.

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.

Before and after. Without sizes: pins A and C start as short placeholders; when A's image arrives it grows to 140 pixels, overlapping where pin B sits, so B is pushed down, and the same happens to D under C. Every image load moves pins and CLS adds up. With sizes from the API: pins A, B, C, and D get their final boxes from the start, filled with each image's dominant color, and nothing moves. CLS is 0.without sizesA: loadingpin BC: loadingpin DB and D get pushed downevery load adds to CLSwith sizespin Apin Bpin Cpin Dfinal boxes, dominant colorCLS 0
The dashed outlines show where the images really end. Without sizes, each load pushes the pins below it; with sizes, the boxes are final before any image byte arrives.

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?

Modal in component state

Clicking sets openPinId in state; a dialog renders over the grid.

The URL never changes.

Pros

  • Simplest to build; the grid never unmounts.

Cons

  • No shareable URL, no deep links, no search traffic to pins.
  • Back leaves the page instead of closing the detail.
  • Refresh loses the open pin.

Separate page

Every pin is a normal route /pin/:id that replaces the grid.

Pros

  • Real URLs, server rendering, simple mental model.

Cons

  • The grid unmounts; coming back costs a rebuild and scroll restoration.
  • Opening a pin feels like a page load, not a quick peek.

Modal route

The pin card is a real link to /pin/:id.

A plain click pushes the pin URL with a history state that remembers the grid as the background.

The router renders the background (the grid, still mounted) plus the detail in a dialog. Without that state, as on a deep link or refresh, the same URL renders a full page.

Pros

  • Shareable URL, back closes, grid untouched behind.
  • Deep links and crawlers get a full server-rendered page.
  • Supported by common routers: Next.js intercepting and parallel routes, or the background location pattern in React Router.

Cons

  • Two presentations of one route to build and test.
  • History rules need care: close, pin-to-pin navigation, refresh.

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.

Sequence diagram for opening a pin, between the User, the PinCard, the Router, and the API. The user taps pin 123. The PinCard prevents the default link navigation and pushes /pin/123 with the grid as background. The Router renders the dialog at once from the cached pin. Then it requests the full pin and related pins; the API replies with details and related page 1, and the details render. The user presses Back or Esc; the Router closes the dialog and returns focus to the card; the grid behind is unchanged.UserPinCardRouterAPItap pin 123push /pin/123dialog from cacheGET pin, relateddetails, page 1details renderBack or Escclose, focus cardgrid unchanged
The dialog paints from data we already have, then fills in. Closing is 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/123 with 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's cancel event 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 a close event handler also calls history.back() if the URL still shows the pin, and Android's back gesture, which also fires cancel, takes the same path. Pushing / instead would add a third history entry, and back would then reopen the pin.
  • Back button. The popstate event 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/456 with 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: hidden on the root element, and scrollbar-gutter: stable keeps the grid from shifting sideways when the scrollbar disappears.
State machine of the pin route. From Grid, tapping a pin pushes the pin URL and enters Modal over grid. In Modal, tapping a related pin pushes again and stays in Modal. Back, Esc, or close returns from Modal to Grid. A refresh in Modal goes to Full page. A deep link also lands on Full page, which is server-rendered. From Full page, close navigates to / and shows Grid.Grid/ stays mountedModal over grid/pin/123 + bgFull page/pin/123, SSRtap pin: pushback, Esc, closetap related: pushrefreshclose: go to /deep link
One URL, two presentations. Only the in-app path with a background in history state shows the modal; everything else gets the full server-rendered page.

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.

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 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.

When the user scrolls near them

An IntersectionObserver on the related section starts the first request.

Pros

  • No request at all for users who only glance at the pin.

Cons

  • The user who does scroll sees an empty area and waits a full round trip plus image loads.
  • It's the most common next action, so we'd be slowing the common case.

In parallel with the pin's details

Opening the detail starts two requests at once: the full pin and related page 1.

Pros

  • Related pins are usually ready by the time the user scrolls.
  • Two small requests over one HTTP/2 or HTTP/3 connection cost little extra.

Cons

  • Some requests are wasted when the user closes the detail right away.

Inside the pin response

The pin endpoint embeds related page 1.

Pros

  • One round trip; good for the server-rendered full page.

Cons

  • The pin details wait for the slower related query, which runs a recommendation model.
  • Couples two caches with different lifetimes.

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?

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?

After the server confirms

Disable the button, send the request, update the UI on success.

Pros

  • The UI never shows anything that isn't true.
  • No rollback code.

Cons

  • Every save feels as slow as the network.
  • Spinners on a frequent action make the app feel heavy, and INP only measures the time to the next paint (the spinner), so the slow part hides from that metric while users still feel it.

Optimistic, send immediately

Update the store at once so the button reads "Saved" and a toast says "Saved to Recipes".

Send the request with an idempotency key; on failure, roll back and say so.

Pros

  • Instant feedback on every save.
  • Undo is just another request.

Cons

  • Needs rollback logic and a clear error message.
  • The UI briefly shows a save that might fail.

Optimistic with a delayed send

Update the UI at once but wait 5 seconds before sending, so Undo can cancel locally.

Pros

  • Undo never touches the server.

Cons

  • If the tab closes in those 5 seconds, the save is lost.
  • Other devices and the ranking see the save late.

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.

Sequence diagram for saving a pin, between the User, the Save queue, the Pin store, and the API. The user taps Save. The Save queue applies saved to Recipes in the Pin store at once, and the user sees Saved and a toast. Then it posts the save with idempotency key k1, and the API replies 201 Created. Later a second save fails while the server is down: the queue posts with key k2, the API answers 503 and retries fail, the queue rolls back the Pin store, and the user sees a Couldn't save message.UserSave queuePin storeAPItap Savesaved: RecipesSaved, toastPOST, key k1201 Createdlater, API downPOST, key k2503, retries failroll backCouldn't save
The screen changes before the request leaves. On failure the queue retries with the same 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-Key header. 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.

State machine of a pin's Save button. Not saved goes to Saving when the user taps Save; Saving already shows Saved. On a timeout, Saving retries with the same key and stays in Saving. When the server confirms with 201, Saving goes to Saved. If retries give up, it rolls back to Not saved. From Saved, Undo goes to Undoing, which sends a DELETE; on 204 it returns to Not saved.Not savedred Save buttonSavingalready shows SavedSavedserver agreesUndoingDELETE senttap Savegive up: roll backretry, same key201 confirmedUndo204
Saving and Saved look the same to the user; only the failure path shows the difference. Retries never leave the Saving state, so the button doesn't flicker.

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.

PartResponsibilityWhy the boundary is here
RouterMaps 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.
MasonryFeedOne 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 engineA 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.
VirtualizerPicks 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 PinImageDraw one pin: the dominant-color box, the img with srcset, the title, hover actions.Owns the image priority tier it is given, nothing else.
PinDetailThe dialog or the full page: large image, metadata, Save, related MasonryFeed.One component, two presentations chosen by the router.
Query cacheFeed 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 storeNormalized pin records by id, including the viewer's save state.One record per pin, so every grid agrees after a save.
Save queueOptimistic saves, idempotency keys, ordering, retries, rollback.Write logic is subtle and must not be duplicated across buttons.
API clientfetch with auth, AbortController, timeouts, error mapping.One place for network policy.
Impression loggerRecords which pins were actually seen and batches them to the server.Feeds the ranking without slowing scrolling.
Pinterest client architecture. Inside the browser: the Router renders MasonryFeed and PinDetail. MasonryFeed reads pages from the Query cache and pins from the Pin store. PinDetail reads the Pin store. The Query cache normalizes pins into the Pin store. The Save queue writes optimistic changes to the Pin store. The Query cache and the Save queue both go through the API client. Outside: the Image CDN serves images to the grid and the detail, the SSR server sends HTML with the first page, and the API serves JSON to the API client.BrowserRoutermodal routePinDetaildialog or pageMasonryFeedlayout, windowPin storepins by idQuery cachefeeds by keySave queueoptimistic, keysAPI clientpagesapplyImage CDNAVIF, WebPSSR serverHTML, page 1APIJSONimage bytesfirst HTML
Each box owns one job. Feeds hold ids and pins live once in the Pin store, so a save shows everywhere at once. Images never go through our API: they come straight from the CDN.

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.

Data flow for the home feed. The Feed API gives page 1 to the SSR server, which sends HTML with page 1 JSON inline to the browser's Query cache. Later pages come from the Feed API to the Query cache as JSON. The Query cache normalizes pins into the Pin store, which feeds MasonryFeed, which computes boxes for each PinCard. Image bytes go from the Image CDN straight to each PinCard. A Save tap on a PinCard goes to the Save queue, which applies an optimistic change to the Pin store and posts the save to the Feed API.SSR serverrenders page 1Feed APIfeeds, savesImage CDNresized imagesQuery cachepages by feedPin storepins by idMasonryFeedlayout, windowPinCardimg, SaveSave queueoptimisticpage 1HTML, page 1 JSONlater pagesnormalizepinsboxesimage bytesPOST
Three kinds of payload cross the network: HTML with the first page inline, JSON for later pages and saves, and image bytes, which never pass through our servers. Saves loop back through the Pin store, so every grid updates at once.

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.

ts
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
Client-side entities. Feeds hold ids; pins live once.
The client data model in four places. The Query cache holds Feeds, each with a key and status, pointing to FeedPages with pin ids and a cursor, and the user's Boards for the picker. The Pin store holds PinSummary records with size, color, and saved state, and PinDetail, which extends a summary with description and source. The URL holds the route, such as /pin/123 or a search query. sessionStorage holds a ScrollAnchor per history entry with a pin id and offset. Boxes are derived in the grid and never stored.Query cacheFeedkey, statusFeedPageids, cursorBoards, for the pickerPin storePinSummarysize, color, savedPinDetail+ description, sourceURLroute/pin/123, ?q=pastasessionStorageScrollAnchorpinId, offsetboxes: derived in the grid, never stored
Feeds point to pins by id; each pin exists once. The URL and the scroll anchor are small, so they survive reloads and back navigation cheaply.

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.

http
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
}
Reads: one page shape for every feed.
http
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 } }
Writes: saving and unsaving, safe to retry.

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.

Tab order in two masonry layouts, numbered in the order Tab visits pins. Left, pins absolutely positioned with the DOM in feed order: 1, 2, and 3 sit across the top row, then 4 to 9 continue roughly row by row. Right, one container per column: 1, 2, and 3 go down the first column, then 4 to 6 down the second, then 7 to 9 down the third, so in an endless feed Tab never leaves the first column.positioned, feed order123456789one container per column123456789Tab: across, then downTab: down column 1 forever
With positions computed from the feed order, Tab moves left to right and gently down, close to how sighted users scan. With one container per column, Tab follows the DOM down column 1, which never ends in an infinite feed.
  • Structure. The grid uses the ARIA feed pattern: a container with role="feed" and each pin an article with aria-posinset (its index) and aria-setsize (-1 because the total is unknown). The container sets aria-busy="true" while a page is being added and back to false when 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-within as 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 body and 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.

BudgetTargetWhat keeps it there
LCP, 75th percentile2.5 s or lessServer HTML with the first images, fetchpriority="high", preconnect to the image CDN, right-sized AVIF.
CLS, 75th percentile0.05 or less, stricter than the 0.1 threshold for goodBoxes sized from API dimensions; layout recomputed once at most on hydration.
INP, 75th percentile200 ms or lessOptimistic saves, the detail opening from cached data, no heavy work in tap handlers.
JavaScript on first loadAbout 150 KB compressedThe detail, board picker, and search suggestions are split into chunks loaded on first use or idle prefetch.
First-screen image bytes on a phoneAbout 400 KBFixed widths, AVIF or WebP, phone density capped near 2x.
DOM sizeUnder 1,500 nodesVirtualized grid with about 20 to 40 mounted pins.
Scroll framesNo long tasks over 50 ms while scrollingOne 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:

MinutesWhat you doWhat to draw or say
0 to 5Scope 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 10Wireframe and architecture.Draw the grid with the detail over it, then the boxes: router, MasonryFeed, caches, API, image CDN.
10 to 20Masonry layout and virtualization.Shortest-column placement from known sizes; per-column binary search; load-more on the shortest column.
20 to 28Images.Three priority tiers, srcset with fixed widths, AVIF, dominant-color placeholders, zero CLS.
28 to 36Pin detail as a modal route, then saving.History rules and focus; optimistic save with idempotency key and rollback.
36 to 42Data model, API, accessibility, budgets.Feeds hold ids, pins live once; the feed pattern; the budget table.
42 to 45Wrap 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?"

Think first

"The user rotates their tablet from 4 columns to 5. What happens?"

Think first

"How would you add ads to the grid?"

  • 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 srcset and sizes, 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next