Editable diagram: Open in Excalidraw

Frontend System Design Interview: Pinterest (Staff Engineer)
Use this as your interview checklist. The goal at staff level is not only to build a feed UI, but to show architecture decisions, tradeoffs, scaling strategy, and product impact.
1. Clarify Scope First (2-3 minutes)
- Define platform: web only, mobile web, or both.
- Define key surfaces: home feed, pin detail, search, board page, profile.
- Confirm read vs write focus: browsing only or include create/save/comment.
- Confirm constraints: low-end devices, slow networks, SEO, accessibility, internationalization.
Interview prompt to say
"I will optimize for fast feed load, smooth scrolling, reliable image delivery, and maintainable architecture for rapid product iteration."
2. Define Success Metrics (Product + Technical)
- Product: session length, saves, CTR on pins, return rate.
- Performance: FCP, LCP, INP, CLS, feed time-to-interactive.
- Reliability: error rate, failed image load rate, API fallback rate.
- Efficiency: bundle size, memory footprint, request count per session.
3. High-Level Frontend Architecture
- App shell + route-based code splitting.
- Feature modules:
- Feed rendering module
- Search/discovery module
- Auth/user state module
- Board/save actions module
- Observability module
- Data layer:
- Request client with retries/backoff
- Cache layer (memory + persistent cache)
- Normalized entities for pins/users/boards
- Rendering strategy:
- SSR or SSG for landing/search pages if SEO is required
- CSR for highly personalized feed sections
4. Data Model You Should Mention
- Pin: id, media URLs, dimensions, title, creator, stats, save state.
- User: id, follows, preferences, locale.
- Board: id, title, pin references.
- Feed chunk: cursor, list of pin ids, ranking metadata.
5. Feed Experience (Core of Pinterest)
A. Masonry layout
- Use CSS columns or grid + JS positioning.
- Pre-calculate item aspect ratio boxes to reduce layout shift.
- Keep stable placeholders and skeletons.
B. Infinite scroll
- Cursor-based pagination (not offset).
- Fetch next page with IntersectionObserver.
- Deduplicate pin ids across pages.
- Guard against request storms with in-flight request lock.
C. Virtualization strategy
- Basic list virtualization for very long sessions.
- Keep a window of rendered items + overscan.
- Restore scroll position on back navigation.
6. Image Delivery and Performance
- Responsive images (
srcset,sizes). - Modern formats (WebP/AVIF) with fallback.
- Lazy load offscreen content.
- Priority load above-the-fold images.
- CDN resizing and quality parameters.
- Blur-up placeholders and dominant-color backgrounds.
7. State Management Strategy
- Separate server state from UI state.
- Server state: feed pages, pin entities, save mutations.
- UI state: filters, selected board modal, hover states.
- Optimistic updates for save/unsave with rollback on failure.
8. Search and Personalization
- Debounced query input.
- Suggestion API + recent search cache.
- Personalization boundary:
- Backend owns ranking
- Frontend owns presentation and interaction latency
9. Accessibility and UX Quality
- Keyboard navigation for grid items.
- ARIA labels for save buttons and media cards.
- Focus management for dialogs and overlays.
- Reduced motion support.
- Color contrast compliance.
10. Error Handling and Resilience
- Component-level fallbacks for failed images.
- Retry strategy with exponential backoff.
- Partial render when some feed cards fail.
- Offline-friendly behavior for cached pins.
11. Security and Abuse Considerations
- Sanitize user-generated text.
- Safe media URL handling.
- CSRF/XSS protections (mostly platform-level, but mention frontend guardrails).
- Rate-limit sensitive actions (save, comment spam signals).
12. Observability and Experimentation
- Track:
- feed_load_start/feed_load_end
- image_load_success/fail
- pin_impression/click/save
- Correlate client metrics with backend feed version and experiment variant.
- Add feature flags for controlled rollout.
13. Staff-Level Tradeoffs to Discuss
- SSR for SEO vs CSR for product velocity.
- Rich interactions vs low-end device performance.
- Aggressive prefetch vs bandwidth/memory cost.
- Deep caching vs stale personalization risk.
- Centralized design system vs feature-team autonomy.
14. 45-60 Minute Interview Talk Track
- 0-5 min: scope + requirements + success metrics.
- 5-15 min: architecture and data flow.
- 15-30 min: feed rendering (masonry, pagination, virtualization).
- 30-40 min: performance, accessibility, reliability.
- 40-50 min: tradeoffs, scaling, rollout plan.
- 50-60 min: Q and A, edge cases, future extensions.
15. Common Follow-Up Questions (Prepare Answers)
- How do you prevent CLS in masonry grids?
- How do you recover when a page in the cursor chain fails?
- How would you support real-time ranking updates?
- How do you debug scroll jank in production?
- When do you choose virtualization vs native long list rendering?
16. What Interviewers Look For at Staff Level
- Clear decomposition and prioritization.
- Strong performance instincts with measurable outcomes.
- Practical API contracts and failure-mode thinking.
- Strong tradeoff communication, not just "best practice" lists.
- Organization-level thinking: platform, rollout, and maintainability.
Quick Checklist Before You Finish Answering
- Did I define explicit assumptions?
- Did I mention metrics and how to measure success?
- Did I cover performance + accessibility + reliability?
- Did I call out at least 3 real tradeoffs?
- Did I present an incremental rollout strategy?
Masonry Layout System Design
Problem Statement
Design a Pinterest-like masonry layout where cards with different heights are arranged into columns with minimal vertical gaps.
Common use cases include:
- Pinterest-style feeds
- Photo galleries
- Product discovery grids
- Marketplace listings
- News or media card layouts
The solution should support:
- Variable image and card heights
- Pagination and infinite scroll
- Responsive column count
- Smooth loading
- Correct image sizing
- Good render performance
- Stable layout with minimal layout shift
What Is Masonry Layout?
A normal grid lays out items row by row:
[ A ][ B ][ C ]
[ D ][ E ][ F ]
When card heights vary, a normal grid can create gaps:
[ Tall A ][ Short B ][ Medium C ]
[ ][ D ][ E ]
A masonry layout places the next item into the shortest column:
Column 1 Column 2 Column 3
A B C
A D C
E D F
The goal is not perfect sorting. The goal is to create visually balanced columns while preserving a reasonable feed order.
High-Level Approaches
Option 1: CSS Columns
.masonry {
column-count: 3;
column-gap: 16px;
}
.item {
break-inside: avoid;
margin-bottom: 16px;
}
Pros
- Very simple
- Browser handles layout
- Good for static content
Cons
- Items flow vertically first, not left-to-right
- Harder to preserve feed order
- Harder to virtualize
- Harder to support pagination predictably
- Not ideal for complex interactive cards
This approach is acceptable for simple static galleries.
Option 2: CSS Grid Masonry
There is emerging CSS support for masonry-like grid layouts, but it is not yet something I would rely on for broad production usage.
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: masonry;
}
Pros
- Clean native API eventually
- Browser layout engine can handle placement
Cons
- Browser support is not consistently available across major browsers
- Risky for production if cross-browser compatibility is required
Option 3: JavaScript-Calculated Masonry
This is the most controllable approach.
The layout engine calculates:
- Number of columns
- Column width
- Rendered item height
- Which column each item should go into
- Absolute
xandyposition for every item - Container height
This gives us control over:
- Pagination
- Infinite scroll
- Virtualization
- Animations
- Responsive behavior
- Stable layout before images load
For a product-grade feed, this is usually the strongest answer.
Core Layout Algorithm
Maintain an array of column heights.
For each item:
- Find the column with the smallest height.
- Place the item in that column.
- Set its
xposition based on column index. - Set its
yposition based on current column height. - Increase that column height by item height plus gap.
Example:
let columnHeights = [0, 0, 0];
// Item A height 200 -> column 0
columnHeights = [216, 0, 0];
// Item B height 100 -> column 1
columnHeights = [216, 116, 0];
// Item C height 300 -> column 2
columnHeights = [216, 116, 316];
// Item D height 150 -> column 1
columnHeights = [216, 282, 316];
JavaScript Layout Function
/**
* Calculate masonry positions.
*
* @param {Object} params
* @param {Array} params.items - Items with width and height metadata.
* @param {number} params.containerWidth - Width of the masonry container.
* @param {number} params.columnWidth - Desired minimum column width.
* @param {number} params.gap - Gap between items.
* @returns {{
* positionedItems: Array,
* containerHeight: number,
* columnCount: number
* }}
*/
function calculateMasonryLayout({ items, containerWidth, columnWidth = 240, gap = 16 }) {
const columnCount = Math.max(1, Math.floor((containerWidth + gap) / (columnWidth + gap)));
const actualColumnWidth = (containerWidth - gap * (columnCount - 1)) / columnCount;
const columnHeights = new Array(columnCount).fill(0);
const positionedItems = items.map((item) => {
const shortestColumnIndex = findShortestColumn(columnHeights);
const aspectRatio = item.height / item.width;
const renderedHeight = actualColumnWidth * aspectRatio;
const x = shortestColumnIndex * (actualColumnWidth + gap);
const y = columnHeights[shortestColumnIndex];
columnHeights[shortestColumnIndex] += renderedHeight + gap;
return {
...item,
x,
y,
width: actualColumnWidth,
height: renderedHeight,
columnIndex: shortestColumnIndex,
};
});
const containerHeight = Math.max(...columnHeights) - gap;
return {
positionedItems,
containerHeight,
columnCount,
};
}
function findShortestColumn(columnHeights) {
let shortestIndex = 0;
for (let i = 1; i < columnHeights.length; i++) {
if (columnHeights[i] < columnHeights[shortestIndex]) {
shortestIndex = i;
}
}
return shortestIndex;
}
Rendering the Layout
The container uses position: relative.
Each item uses position: absolute.
<div class="masonry" style="height: 1200px">
<div class="masonry-item" style="transform: translate(0px, 0px)">...</div>
<div class="masonry-item" style="transform: translate(256px, 0px)">...</div>
</div>
Use transform: translate(...) instead of top and left where possible because transforms are generally more animation-friendly and avoid some layout recalculation costs.
.masonry {
position: relative;
width: 100%;
}
.masonryItem {
position: absolute;
will-change: transform;
}
Example item renderer:
function MasonryItem({ item }) {
return (
<div
className="masonryItem"
style={{
width: item.width,
height: item.height,
transform: `translate(${item.x}px, ${item.y}px)`,
}}
>
<img src={item.src} alt={item.alt ?? ''} width={item.width} height={item.height} />
</div>
);
}
React TypeScript Implementation
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
export type MasonryItemData = {
id: string;
src: string;
alt?: string;
width: number;
height: number;
};
type PositionedMasonryItem = MasonryItemData & {
x: number;
y: number;
renderedWidth: number;
renderedHeight: number;
columnIndex: number;
};
type MasonryLayoutResult = {
items: PositionedMasonryItem[];
containerHeight: number;
columnCount: number;
};
type MasonryProps = {
items: MasonryItemData[];
columnWidth?: number;
gap?: number;
};
export function Masonry({ items, columnWidth = 240, gap = 16 }: MasonryProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
useLayoutEffect(() => {
if (!containerRef.current) return;
const element = containerRef.current;
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
setContainerWidth(entry.contentRect.width);
});
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
};
}, []);
const layout = useMemo(() => {
if (containerWidth === 0) {
return {
items: [],
containerHeight: 0,
columnCount: 1,
};
}
return calculateMasonryLayout({
items,
containerWidth,
columnWidth,
gap,
});
}, [items, containerWidth, columnWidth, gap]);
return (
<div
ref={containerRef}
className="masonry"
style={{
position: 'relative',
width: '100%',
height: layout.containerHeight,
}}
>
{layout.items.map((item) => (
<div
key={item.id}
className="masonryItem"
style={{
position: 'absolute',
width: item.renderedWidth,
height: item.renderedHeight,
transform: `translate(${item.x}px, ${item.y}px)`,
}}
>
<img
src={item.src}
alt={item.alt ?? ''}
width={item.renderedWidth}
height={item.renderedHeight}
loading="lazy"
decoding="async"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
</div>
))}
</div>
);
}
function calculateMasonryLayout({
items,
containerWidth,
columnWidth,
gap,
}: {
items: MasonryItemData[];
containerWidth: number;
columnWidth: number;
gap: number;
}): MasonryLayoutResult {
const columnCount = Math.max(1, Math.floor((containerWidth + gap) / (columnWidth + gap)));
const actualColumnWidth = (containerWidth - gap * (columnCount - 1)) / columnCount;
const columnHeights = new Array(columnCount).fill(0);
const positionedItems = items.map((item) => {
const shortestColumnIndex = findShortestColumn(columnHeights);
const aspectRatio = item.height / item.width;
const renderedHeight = actualColumnWidth * aspectRatio;
const x = shortestColumnIndex * (actualColumnWidth + gap);
const y = columnHeights[shortestColumnIndex];
columnHeights[shortestColumnIndex] += renderedHeight + gap;
return {
...item,
x,
y,
renderedWidth: actualColumnWidth,
renderedHeight,
columnIndex: shortestColumnIndex,
};
});
return {
items: positionedItems,
containerHeight: Math.max(...columnHeights, 0),
columnCount,
};
}
function findShortestColumn(columnHeights: number[]) {
let shortestIndex = 0;
for (let i = 1; i < columnHeights.length; i++) {
if (columnHeights[i] < columnHeights[shortestIndex]) {
shortestIndex = i;
}
}
return shortestIndex;
}
CSS
.masonry {
position: relative;
width: 100%;
}
.masonryItem {
position: absolute;
overflow: hidden;
border-radius: 12px;
background: #f3f4f6;
}
.masonryItem img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
Handling Pagination with Masonry
When new items are fetched, avoid recalculating all previous items unless a full responsive reflow is required.
Bad approach:
Fetch page 1
Calculate layout
Fetch page 2
Recalculate page 1 and page 2 from scratch
Old items may move
Better approach:
Fetch page 1
Calculate layout
Fetch page 2
Continue from existing column heights
Append new items only
This gives a more stable feed because old items do not jump when new items arrive.
Incremental Masonry Layout for Pagination
function appendMasonryItems({
existingItems,
newItems,
columnHeights,
containerWidth,
columnCount,
gap,
}) {
const actualColumnWidth = (containerWidth - gap * (columnCount - 1)) / columnCount;
const positionedNewItems = newItems.map((item) => {
const shortestColumnIndex = findShortestColumn(columnHeights);
const aspectRatio = item.height / item.width;
const renderedHeight = actualColumnWidth * aspectRatio;
const x = shortestColumnIndex * (actualColumnWidth + gap);
const y = columnHeights[shortestColumnIndex];
columnHeights[shortestColumnIndex] += renderedHeight + gap;
return {
...item,
x,
y,
renderedWidth: actualColumnWidth,
renderedHeight,
columnIndex: shortestColumnIndex,
};
});
return {
items: [...existingItems, ...positionedNewItems],
columnHeights,
containerHeight: Math.max(...columnHeights),
};
}
Infinite Scroll with IntersectionObserver
Use a sentinel element near the bottom of the page.
function useInfiniteScroll({
hasMore,
isLoading,
onLoadMore,
}: {
hasMore: boolean;
isLoading: boolean;
onLoadMore: () => void;
}) {
const sentinelRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && hasMore && !isLoading) {
onLoadMore();
}
},
{
root: null,
rootMargin: '600px',
threshold: 0,
}
);
observer.observe(sentinel);
return () => {
observer.disconnect();
};
}, [hasMore, isLoading, onLoadMore]);
return sentinelRef;
}
Usage:
function MasonryFeed() {
const [items, setItems] = React.useState<MasonryItemData[]>([]);
const [page, setPage] = React.useState(1);
const [isLoading, setIsLoading] = React.useState(false);
const [hasMore, setHasMore] = React.useState(true);
const loadMore = React.useCallback(async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
try {
const response = await fetch(`/api/photos?page=${page}`);
const data = await response.json();
setItems((prev) => dedupeById([...prev, ...data.items]));
setPage((prev) => prev + 1);
setHasMore(data.hasMore);
} finally {
setIsLoading(false);
}
}, [page, isLoading, hasMore]);
const sentinelRef = useInfiniteScroll({
hasMore,
isLoading,
onLoadMore: loadMore,
});
return (
<>
<Masonry items={items} />
<div ref={sentinelRef} style={{ height: 1 }} />
{isLoading && <p>Loading...</p>}
</>
);
}
function dedupeById(items: MasonryItemData[]) {
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
}
Staff-Level Pagination Considerations
For production, prefer cursor-based pagination:
GET /api/feed?cursor=abc123&limit=30
Instead of offset pagination:
GET /api/feed?page=5
Cursor pagination is more stable when new items are inserted.
Example API response:
{
"items": [
{
"id": "photo_1",
"src": "https://cdn.example.com/photo_1.jpg",
"width": 1200,
"height": 1600
}
],
"nextCursor": "def456",
"hasMore": true
}
The API should return original image dimensions. Without width and height, the client cannot reserve the correct space before images load.
Making Sure Image Size Renders Correctly
The most important rule:
Know the image aspect ratio before rendering.
Each item should include width and height metadata:
type ImageItem = {
id: string;
src: string;
width: number;
height: number;
};
Then calculate the rendered height:
const aspectRatio = item.height / item.width;
const renderedHeight = columnWidth * aspectRatio;
This prevents layout shift.
Correct Image Rendering
<img
src={item.src}
alt={item.alt ?? ''}
width={item.renderedWidth}
height={item.renderedHeight}
loading="lazy"
decoding="async"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
Why this matters:
widthandheighthelp the browser reserve spaceobject-fit: coverprevents distortiondisplay: blockremoves inline image spacingloading="lazy"avoids loading all images immediatelydecoding="async"avoids blocking rendering
Handling Unknown Image Dimensions
Sometimes the API does not provide image width and height.
Option 1: Preload and Measure
function getImageSize(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve({
width: img.naturalWidth,
height: img.naturalHeight,
});
};
img.onerror = reject;
img.src = src;
});
}
Pros
- Accurate
Cons
- Slower first render
- More network pressure
- More complicated pagination behavior
Option 2: Use Estimated Height First, Then Correct Later
const estimatedHeight = 300;
After image load, measure the actual image and update layout.
Pros
- Faster initial render
Cons
- Causes layout shift
- Recalculating layout can move items
- Bad UX for feeds
Option 3: Backend Generates Metadata
Best production solution:
- Image upload pipeline extracts width and height
- Store dimensions in the database
- API returns dimensions with every item
This is what I would choose for a serious production system.
Render Performance
Avoid Expensive Layout Recalculation
The layout algorithm is O(n * c), where:
nis the number of itemscis the number of columns
Since c is usually small, this is effectively O(n).
For 3 to 6 columns, this is fine.
For very large feeds, use:
- Incremental layout
- Virtualization
- Memoization
- Windowing
- Resize throttling
Use useMemo
const layout = useMemo(() => {
return calculateMasonryLayout({
items,
containerWidth,
columnWidth,
gap,
});
}, [items, containerWidth, columnWidth, gap]);
Avoid Layout Thrashing
Bad approach:
items.forEach((item) => {
const height = item.element.getBoundingClientRect().height;
item.element.style.top = nextTop + 'px';
});
This mixes DOM reads and writes repeatedly.
Better approach:
- Calculate layout from known metadata.
- Render styles in one React pass.
- Avoid measuring every DOM node after render.
Use ResizeObserver
Do not listen to every window.resize event directly without throttling.
useLayoutEffect(() => {
const observer = new ResizeObserver((entries) => {
const width = entries[0].contentRect.width;
setContainerWidth(width);
});
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, []);
For high-frequency resizing, debounce the width update:
function debounce(fn, delay) {
let timerId;
return (...args) => {
clearTimeout(timerId);
timerId = setTimeout(() => {
fn(...args);
}, delay);
};
}
Virtualization for Large Feeds
If the feed has thousands of items, rendering everything is too expensive.
Only render items whose y position is near the viewport.
function getVisibleItems({ items, scrollTop, viewportHeight, overscan = 800 }) {
const minY = scrollTop - overscan;
const maxY = scrollTop + viewportHeight + overscan;
return items.filter((item) => {
return item.y + item.renderedHeight >= minY && item.y <= maxY;
});
}
Then render only visible items:
const visibleItems = useMemo(() => {
return getVisibleItems({
items: layout.items,
scrollTop,
viewportHeight,
overscan: 800,
});
}, [layout.items, scrollTop, viewportHeight]);
Scroll tracking:
React.useEffect(() => {
let frameId = 0;
function onScroll() {
cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(() => {
setScrollTop(window.scrollY);
});
}
window.addEventListener('scroll', onScroll, { passive: true });
return () => {
cancelAnimationFrame(frameId);
window.removeEventListener('scroll', onScroll);
};
}, []);
Production Masonry Architecture
Client
|
| GET /feed?cursor=abc&limit=30
v
Feed API
|
| returns items with image metadata
v
Masonry Layout Engine
|
| calculates x, y, width, height
v
Virtualized Renderer
|
| renders visible cards
v
Image CDN
|
| serves optimized responsive images
Responsive Behavior
Column count should change based on container width.
const columnCount = Math.max(1, Math.floor((containerWidth + gap) / (minColumnWidth + gap)));
Example behavior:
< 600px -> 1 column
600-900px -> 2 columns
900-1200px -> 3 columns
1200px+ -> 4 or 5 columns
When column count changes, we usually need to recalculate layout from scratch because item positions depend on the number of columns.
This is acceptable on resize because users expect responsive layout reflow.
Avoiding Layout Shift
Main techniques:
- Return image dimensions from the API.
- Reserve image space before image loads.
- Use placeholders or skeletons with the correct aspect ratio.
- Avoid changing item height after render.
- Use stable item keys.
- Avoid recalculating already-rendered pagination pages unless responsive column count changes.
Example skeleton:
<div
className="imageSkeleton"
style={{
width: item.renderedWidth,
height: item.renderedHeight,
}}
/>
Image CDN and Responsive Images
For production, do not serve full-size images into small cards.
Use CDN resizing:
<img
src={`${item.src}?w=${Math.round(item.renderedWidth * 2)}`}
srcSet={`
${item.src}?w=${Math.round(item.renderedWidth)} 1x,
${item.src}?w=${Math.round(item.renderedWidth * 2)} 2x
`}
alt={item.alt ?? ''}
loading="lazy"
decoding="async"
/>
This improves:
- Load time
- Memory usage
- Mobile performance
- Bandwidth cost
Data Model
type FeedItem = {
id: string;
title: string;
imageUrl: string;
imageWidth: number;
imageHeight: number;
createdAt: string;
};
Layout model:
type PositionedFeedItem = FeedItem & {
x: number;
y: number;
renderedWidth: number;
renderedHeight: number;
columnIndex: number;
};
Pagination model:
type FeedResponse = {
items: FeedItem[];
nextCursor: string | null;
hasMore: boolean;
};
Edge Cases
Empty List
if (items.length === 0) {
return {
items: [],
containerHeight: 0,
columnCount: 1,
};
}
Broken Images
Use fallback UI:
function ImageCard({ item }) {
const [failed, setFailed] = React.useState(false);
if (failed) {
return <div className="fallback">Image unavailable</div>;
}
return <img src={item.src} alt={item.alt ?? ''} onError={() => setFailed(true)} />;
}
Very Tall Images
Clamp max height if the product requirement needs it:
.cardImage {
max-height: 640px;
object-fit: cover;
}
Be careful: clamping can change the visual representation of the original image.
Items Inserted at the Top
For feeds where new content is inserted at the top, preserving scroll position is important.
Approach:
- Capture the current scroll anchor item.
- Insert new items.
- Recalculate layout.
- Restore scroll so the anchor item stays visually stable.
Staff Engineer Tradeoff Discussion
Simple Marketing Gallery
Use CSS columns.
.gallery {
column-count: 3;
column-gap: 16px;
}
.galleryItem {
break-inside: avoid;
}
This is simple and cheap.
Product Feed
Use JavaScript layout.
Reasons:
- Stable pagination
- Predictable item order
- Easier analytics tracking
- Better support for virtualization
- Better control over loading behavior
Large-Scale Consumer Feed
Use JavaScript layout plus:
- Cursor pagination
- Image metadata from backend
- CDN image resizing
- Virtualized rendering
- Skeleton placeholders
- Incremental append layout
- Observability around layout cost and image loading
Masonry Interview Summary Answer
I would design masonry as a client-side layout engine that places each item into the currently shortest column. The API should return image dimensions so the client can calculate rendered height before images load. The container uses relative positioning, while items are absolutely positioned with transform-based placement.
For pagination, I would use cursor-based pagination and append newly fetched items into the current column-height state instead of recalculating old pages on every fetch. This prevents existing content from jumping. For responsive resizing, I would recalculate from scratch because column count changes.
For performance, I would memoize layout computation, avoid DOM measurement loops, use ResizeObserver, lazy-load images, serve responsive CDN image sizes, and virtualize the feed once item count becomes large. The most important rendering guarantee is preserving aspect ratio and reserving image space before load to avoid layout shift.
Compact Core Masonry Logic
function masonry(items, containerWidth, minColumnWidth = 240, gap = 16) {
const columnCount = Math.max(1, Math.floor((containerWidth + gap) / (minColumnWidth + gap)));
const columnWidth = (containerWidth - gap * (columnCount - 1)) / columnCount;
const columnHeights = Array(columnCount).fill(0);
const positioned = items.map((item) => {
const column = columnHeights.indexOf(Math.min(...columnHeights));
const height = columnWidth * (item.height / item.width);
const x = column * (columnWidth + gap);
const y = columnHeights[column];
columnHeights[column] += height + gap;
return {
...item,
x,
y,
width: columnWidth,
height,
};
});
return {
items: positioned,
height: Math.max(...columnHeights),
};
}
This is the core of masonry layout.