Free lesson · Frontend System Design
Designing Instagram (Photo Sharing) - HLD Case Study
What we're designing and the defining pressure
"Design Instagram" almost always means design the home feed - the vertically scrolling stream of photo and video posts that is the app's center of gravity. We'll scope to that surface (with profile and post pages as close neighbors) because it's where the interesting frontend architecture lives, and we'll apply the RADIO framework: requirements, architecture, data model, interface, and the optimizations and deep dives where the round is won. The skill in any HLD is to find the defining pressure - the one or two forces that make this product's frontend hard - and route the design around it. For Instagram, the defining pressure is unmistakable: media. The bytes on the wire are overwhelmingly images and video, not JSON; the perceived performance is dominated by how fast media appears and whether it janks the scroll; and the memory and network budgets are spent almost entirely on media. So the two deep dives that matter most are media delivery and rendering and the multi-layer caching that serves it, sitting on top of a cursor-paginated infinite feed built from the machine-coding patterns of the previous module.
Everything else in the design exists to serve that media pressure. The feed is cursor-paginated because a media-heavy infinite feed must stay stable as new posts arrive and must load fast at any depth (the pagination Part 2 reasoning). The client cache is normalized so a like or comment updates instantly and consistently everywhere a post appears, without refetching expensive payloads. The rendering strategy balances a fast first paint against a client-driven scroll. And virtualization eventually enters because an unbounded feed of decoded images would exhaust memory. The through-line - the thing to narrate - is the journey of a single post's media from a CDN edge, through the caching layers, into a layout-stable, lazily-loaded, progressively-revealed image on a phone over a flaky network. Get that journey right and the rest of the design follows.
R - Requirements
Functional. A vertical home feed of posts; each post has an author, one or more media items (photo, a swipeable carousel, or short video), a caption, a like count, a comment count and preview, and a saved state. Infinite scroll through the feed. Like (heart / double-tap), comment, and save. Pull-to-refresh for newer posts. Adjacent surfaces: a profile grid and a single-post page (both reuse the same Post and media components). We'll treat stories and direct messages as out of scope to keep the focus on the feed.
Non-functional. This is where the media pressure gets stated explicitly, because it justifies the whole design. Fast first paint even on mobile over 3G/4G - the first posts must appear quickly. Smooth 60fps scrolling through heavy media - no jank as images load and decode. No layout shift (CLS) - images must not push content around as they arrive. Aggressive caching - revisits and back-navigation must be instant, not a refetch. Bounded memory - a long scroll session must not accumulate unbounded decoded images. Accessibility - alt text, keyboard, and non-gesture alternatives to double-tap. Freshness is soft-realtime: the feed is mostly pull-to-refresh, and like counts can be eventually consistent (unlike a chat, which is hard-realtime - that's the next case study).
Scale (the numbers that shape the design). Hundreds of millions of daily users; the feed is the most-requested surface. The dominant payload is media, not JSON - a single feed page might be a few KB of JSON but several MB of images. Users are mobile-first on variable networks. These three facts - media dominates bytes, mobile dominates devices, the feed dominates traffic - are the scale story, and every later decision (responsive image variants, CDN edge caching, lazy loading, virtualization) ties back to them.
A - Architecture
The layered architecture, with the media path called out because it's the centerpiece. The frontend is a routed application whose feed is client-driven infinite scroll, served by a CDN for assets and media, a BFF that shapes feed responses, and a normalized client cache as the heart of the data layer.
graph TD
subgraph Edge["Delivery / Edge"]
CDN["Static asset CDN<br/>JS/CSS chunks"]
IMG["Image/Video CDN<br/>on-the-fly resize · WebP/AVIF · immutable URLs · long TTL"]
end
subgraph Client["Client (web / app)"]
Shell["App shell + routing<br/>code-split: feed · profile · post · explore"]
Feed["FeedList (infinite scroll + virtualization)"]
Post["Post → PostMedia (responsive, lazy, aspect-boxed)"]
Data["Data layer: normalized cache<br/>postsById · usersById · feed=ordered ids<br/>(React Query: SWR, dedupe)"]
SW["Service worker<br/>shell + recent media cache (repeat-visit / light offline)"]
end
subgraph Transport["Transport"]
API["BFF / Feed API (GraphQL or REST)<br/>cursor pagination · mutations (like/comment)"]
end
CDN --> Shell
Shell --> Feed --> Post
Post <-->|"read/write entities"| Data
Data <-->|"fetch pages · mutate"| API
Post -->|"<img srcset> / <video>"| IMG
SW -.->|"intercepts media + shell"| IMG
API --> Services["feed ranking, posts, social graph (backend - out of FE scope)"]
This diagram shows Instagram's frontend architecture. The delivery edge has two CDNs: a static asset CDN for JavaScript and CSS chunks, and a dedicated image and video CDN that does on-the-fly resizing, serves modern formats like WebP and AVIF, uses immutable URLs, and caches with a long time-to-live. In the client, an app shell with routing code-splits the feed, profile, post, and explore routes. The FeedList does infinite scroll with virtualization, rendering Posts whose media is responsive, lazy, and aspect-boxed. A data layer holds a normalized cache - posts by id, users by id, and the feed as an ordered list of ids - using React Query for stale-while-revalidate and request dedupe. A service worker caches the shell and recent media for repeat visits and light offline. The transport is a backend-for-frontend feed API, over GraphQL or REST, doing cursor pagination and like and comment mutations. The UI reads and writes entities through the data layer, which fetches pages and mutates through the API, while Post elements load images and video directly from the image CDN, which the service worker can intercept. The feed API talks to backend ranking, post, and social-graph services that are out of frontend scope."
Rendering strategy. For the web app, server-render (or stream) the app shell and the first feed page so the first posts paint fast and post/profile pages are SEO-able, then hand off to client-side infinite scroll for everything below the fold. This hybrid - SSR/streamed first paint, CSR for the scroll - is the right call because the first impression is latency-critical (especially on mobile) while the open-ended scroll is inherently client-driven. The native apps render natively, but the data-layer and media strategies described here are the same. Committing to this rendering strategy early matters because it cascades into caching (the SSR'd first page should be reused by the client cache, not refetched) and into the media strategy (the first page's images get priority hints; below-the-fold images are lazy).
D - Data model (client-side shape)
The data model that matters for a frontend HLD is the client-side shape, because it drives cache cost and update consistency. Instagram's entities are Users, Posts, MediaItems, and Comments, and the shape is normalized:
// The normalized client cache (conceptual)
{
"postsById": {
"p_123": {
"id": "p_123", "authorId": "u_9", "createdAt": "...",
"media": [
{ "id": "m_1", "type": "image", "aspectRatio": 1.0, // ← crucial for CLS
"blurhash": "L6Pj0^...", "variants": { "url": "/img/m_1" } }
],
"caption": "…", "likeCount": 240, "likedByMe": false,
"commentCount": 12, "savedByMe": false,
"commentPreview": ["c_1", "c_2"]
}
},
"usersById": { "u_9": { "id": "u_9", "handle": "olena", "avatarUrl": "…" } },
"commentsById": { "c_1": { "id": "c_1", "authorId": "u_4", "text": "…" } },
"feed": { "order": ["p_123", "p_124", "p_125"], "nextCursor": "opaque..." }
}
Two design points are doing real work here. First, normalization (posts, users, comments each keyed by id, with the feed as an ordered list of post ids) means a like or a profile-name change updates one entity and reflects everywhere that entity appears - the feed, the profile grid, the post page - without refetching, and a paginated feed appends ids cheaply. This is the comments-and-realtime normalization lesson at app scale. Second, and specific to a media product: each media item stores its aspectRatio (and a blurhash). The aspect ratio lets the client reserve the exact layout box before the image loads, which is the single most important trick for eliminating layout shift; the blurhash gives a tiny embeddable placeholder to show while the real image streams in. Carrying dimensions in the data model is a frontend-specific instinct that an interviewer will notice.
I - Interface / API
Feed pagination - cursor, not offset. The feed is GET /feed?limit=10&cursor=<opaque> → { items: [post…], nextCursor }, using cursor pagination for exactly the reasons from pagination Part 2: a social feed is constantly changing (new posts arrive at the top), so offset pagination would cause page drift (duplicates and skips) as you scroll, and would be slow at depth; a cursor anchored to a stable position is drift-free and fast at any depth. Each post in the payload carries its author, media variant info with dimensions and blurhash, counts, and a comment preview - shaped by the BFF so the client gets exactly what the feed card needs in one round trip (this is where GraphQL is attractive: the feed query asks for precisely the card's fields and the comment preview, avoiding over- and under-fetching; REST with a well-shaped BFF response works too).
Media - responsive variants from an image CDN. Media is not served as a single fixed image. The image CDN exposes multiple resolutions (often via on-the-fly resizing: /img/m_1?w=640&fm=webp), and the client requests the right one via srcset/sizes based on viewport and device pixel ratio, in a modern format (AVIF/WebP with a JPEG fallback). URLs are immutable and content-addressed, so they can be cached at the CDN and browser with a very long TTL. This media API design is the first deep dive.
Mutations - optimistic. POST /posts/:id/like and POST /posts/:id/comments are optimistic: the normalized cache updates instantly (the heart fills, the count increments) and reconciles on the server ack, rolling back on failure - the real-time-updates optimistic pattern, now as the feed's interaction model. Because the cache is normalized, the optimistic like reflects on every surface showing that post at once.
The data-flow loop - opening the feed and scrolling
Before the deep dives, the through-line: what actually happens from cold load through a scroll. Narrating this loop is the backbone of the answer.
sequenceDiagram
participant U as User
participant Shell as App Shell
participant DL as Data Layer
participant API as Feed API
participant CDN as Image CDN
U->>Shell: Open feed
Shell-->>U: Render app shell and first page
Note right of Shell: Server-side rendering provides<br/>the initial page and reserves space<br/>for images to prevent layout shift.
Shell->>DL: Hydrate cache with page 1
DL->>CDN: Load first-page images
CDN-->>U: Images progressively load
Note right of CDN: Blur placeholder transitions<br/>to full-resolution image.
U->>Shell: Scroll down
Shell->>DL: Trigger loadMore when sentinel is visible
DL->>API: Request next page using cursor
API-->>DL: Return items and next cursor
DL->>DL: Normalize data and append item IDs
DL->>CDN: Prefetch images for upcoming posts
U->>Shell: Continue scrolling
Shell->>Shell: Virtualize off-screen posts to free memory
This sequence diagram traces opening and scrolling the Instagram feed. The user opens the feed. The server-rendered or streamed app shell paints the shell and the first page fast, with aspect-ratio boxes already reserving each image's space. The shell hydrates the data layer's normalized cache with the server-rendered first page, so there's no refetch. The data layer loads the first page's media with priority through responsive image tags, and the image CDN streams the images in, going from a blurhash placeholder to the full image with no layout shift. When the user scrolls down, a sentinel near the end triggers loadMore with the cursor; the data layer requests the next feed page from the BFF, deduplicating and caching it, receives items and the next cursor, and normalizes and appends the new post ids. It then prefetches media for the posts just below the fold so they're ready. As the user keeps scrolling, off-screen posts unmount through virtualization, freeing decoded-image memory."
Deep dive 1 - Media delivery and rendering
This is the centerpiece, and it's a stack of techniques that together make a media-heavy feed fast and smooth. We'll build the PostMedia / MediaItem component as the concrete artifact, since it embodies most of them.
Responsive images. Serve the right-sized image for each device rather than one large image scaled down. The CDN offers multiple widths; the client declares its layout intent with sizes and the candidate set with srcset, and the browser picks the best variant for the viewport and DPR - so a phone doesn't download a desktop-sized image. Pair this with modern formats (AVIF/WebP, far smaller than JPEG) using <picture> with a fallback. This alone can cut media bytes by large multiples on mobile, directly addressing the scale story.
Layout stability (no CLS). Because the data model carries each image's aspect ratio, the component reserves the exact box before the image loads (via the CSS aspect-ratio property or a padding-box). The image then fills a space already allocated, so nothing shifts when it arrives - eliminating the janky reflow that plagues naive media feeds and protecting the Core Web Vital.
Progressive reveal. While the full image streams in, show the blurhash (a tiny string decoded to a blurred placeholder) or a low-quality image placeholder, then cross-fade to the full image on load. The user sees something immediately in the right shape, and the full image resolves in.
Lazy loading and prefetch. Only load media that's near the viewport (native loading="lazy" or an IntersectionObserver), so a thousand-post scroll session doesn't download every image up front - this is the infinite-scroll lesson applied to media. Conversely, prefetch the media for the next few posts just below the fold (using IntersectionObserver with a forward rootMargin, or <link rel="prefetch"> / priority hints), so by the time the user scrolls to a post, its image is already decoded. The balance - lazy for far, prefetch for near - is what makes the scroll feel instant without wasting bandwidth.
Decode and main-thread health. Use decoding="async" so image decode doesn't block the main thread and stutter the scroll, and keep media containers compositor-friendly. For video posts, autoplay on visibility (IntersectionObserver) and pause/unload off-screen, so only the visible video plays - and for longer video you'd reach for adaptive streaming (HLS), though Instagram clips are short enough that this is a lighter concern than for Netflix.
Memory. Off-screen decoded images consume significant memory in a long feed; virtualization (deep dive 3) unmounts off-screen posts, which releases their decoded image memory - the rendering and memory stories converge.
// MediaItem: responsive, format-negotiated, layout-stable, progressively revealed, lazy.
function MediaItem({ media, priority = false }) {
const [loaded, setLoaded] = useState(false);
return (
<div
className="media"
style={{ aspectRatio: media.aspectRatio }} // reserve the box → no CLS
>
{/* blurhash placeholder shown until the full image loads */}
{!loaded && <BlurhashCanvas hash={media.blurhash} className="media__placeholder" />}
<picture>
<source
type="image/avif"
srcSet={`${media.url}?w=480&fm=avif 480w, ${media.url}?w=750&fm=avif 750w, ${media.url}?w=1080&fm=avif 1080w`}
sizes="(max-width: 600px) 100vw, 600px"
/>
<source
type="image/webp"
srcSet={`${media.url}?w=480&fm=webp 480w, ${media.url}?w=750&fm=webp 750w, ${media.url}?w=1080&fm=webp 1080w`}
sizes="(max-width: 600px) 100vw, 600px"
/>
<img
src={`${media.url}?w=750`} // JPEG fallback
alt={media.alt ?? ''}
loading={priority ? 'eager' : 'lazy'} // lazy below the fold
decoding="async" // don't block the main thread
fetchPriority={priority ? 'high' : 'auto'} // priority hint for first page
onLoad={() => setLoaded(true)}
className={`media__img${loaded ? ' is-loaded' : ''}`}
/>
</picture>
</div>
);
}
Deep dive 2 - Multi-layer caching
A media feed lives or dies on caching, and the senior move is to recognize there are several distinct cache layers, each solving a different problem. Conflating them is a common weakness; naming them and assigning each a job is the strong answer.
Layer 1 - HTTP/CDN cache (media and assets). Because media URLs are immutable and content-addressed, they're served with a very long cache TTL, so the CDN edge and the browser cache them aggressively - a re-view or back-navigation reuses the bytes with no network at all. This is the highest-leverage cache because media is the dominant payload. Static JS/CSS chunks are likewise content-hashed and long-cached.
Layer 2 - Client data cache (feed JSON). The normalized in-memory store (via React Query or similar) caches feed pages and entities with stale-while-revalidate: returning to the feed shows the cached posts instantly while a background refetch checks for updates. This avoids refetching the (cheap but latency-adding) JSON and, critically, lets the feed re-render on navigation without a spinner. Request dedupe prevents duplicate in-flight fetches.
Layer 3 - Service worker (shell + recent media). A service worker caches the app shell (instant repeat loads, the PWA app-shell pattern from Module 9) and optionally recently-viewed media, enabling fast repeat visits and a degree of offline viewing. This layer is optional but valuable for a mobile-web audience on flaky networks.
Freshness and consistency. Two mechanisms keep the cache honest. Pull-to-refresh fetches the newest cursor page and prepends it. Optimistic mutations update the normalized cache immediately - and because the post is a single normalized entity, a like reflects on the feed, the profile grid, and the post page simultaneously, then reconciles on the server ack with rollback on failure. Scroll restoration rounds it out: returning to the feed restores both the cached data and the scroll position, so back-navigation is instant rather than a reload - a real, often-overlooked Instagram-web requirement that the cache layer makes possible.
// useFeed: cursor-paginated infinite feed over the normalized cache (React Query).
function useFeed() {
const query = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) =>
fetchJSON(`/feed?limit=10${pageParam ? `&cursor=${pageParam}` : ''}`),
getNextPageParam: (lastPage) => lastPage.nextCursor, // cursor pagination
staleTime: 60_000, // serve cached feed, revalidate in bg (SWR)
});
// Flatten pages → post ids; entities are normalized into the cache on write.
const posts = query.data?.pages.flatMap((p) => p.items) ?? [];
return { posts, ...query };
}
// useLikePost: optimistic mutation on the normalized entity → reflects everywhere.
function useLikePost() {
const qc = useQueryClient();
return useMutation({
mutationFn: (postId) => postJSON(`/posts/${postId}/like`),
onMutate: async (postId) => {
await qc.cancelQueries({ queryKey: ['feed'] });
const prev = qc.getQueryData(['feed']);
// Optimistically flip likedByMe + bump count on the single entity.
qc.setQueryData(['feed'], (data) => mapPost(data, postId, (post) => ({
...post, likedByMe: !post.likedByMe,
likeCount: post.likeCount + (post.likedByMe ? -1 : 1),
})));
return { prev }; // rollback handle
},
onError: (_e, _id, ctx) => qc.setQueryData(['feed'], ctx.prev), // rollback
onSettled: () => qc.invalidateQueries({ queryKey: ['feed'] }), // reconcile
});
}
Deep dive 3 - Infinite scroll and virtualization at feed scale
The feed's loading mechanics reuse the machine-coding patterns directly, now justified at system scale. Cursor-based infinite scroll: an IntersectionObserver sentinel near the end of the list triggers loadMore(cursor) (the infinite-scroll lesson), and cursor pagination keeps the growing feed stable as new posts arrive at the top (the pagination Part 2 reasoning) - offset pagination would duplicate or skip posts mid-scroll. Virtualization enters because a media feed scrolled for a long session accumulates enormous DOM and decoded-image memory; windowing the rendered posts keeps both bounded, and crucially unmounting an off-screen post frees its decoded image, which is the memory half of the media story. The wrinkle specific to a photo feed is variable item heights - posts vary by aspect ratio and caption length - so this is variable-height virtualization (measure + cache + offset map, or a library like TanStack Virtual), not the simple fixed-height case. Finally, scroll restoration on back-navigation depends on the cache retaining the feed and the virtualizer being able to jump to a saved offset. The decision discipline still applies: virtualize when the feed is genuinely long; for a short feed, plain rendering plus lazy media is simpler and sufficient.
Cross-cutting: performance, accessibility, rendering
Performance beyond media: code-split the routes (feed, profile, post, explore) so each loads only its chunk, keep a tight mobile bundle budget, and lazy-load non-critical components. The dominant lever, though, is the media work above - image optimization is the performance story for this product. Accessibility: real alt text on images (Instagram supports it, and it's both an a11y and SEO asset), keyboard operability of like/comment/save, a button alternative to double-tap (a gesture must not be the only way to like), focus management on the post page, and respecting reduced motion in transitions. Rendering strategy (restated as a cross-cutting choice): SSR/stream the shell and first page for first paint and SEO, hydrate the client cache from it without refetching, and prioritize first-page media - so the SSR, caching, and media layers reinforce each other rather than duplicate work.
Scaling and tradeoffs
The honest tradeoffs, which are what an interviewer wants to hear named. SSR first paint vs complexity: server-rendering the shell and first page improves time-to-content and SEO but adds server cost and hydration complexity; it's worth it here because first paint on mobile is latency-critical. Cursor vs offset: cursor pagination gives a stable, fast-at-depth feed but sacrifices jump-to-page and totals - the right trade for a feed (you never "jump to page 40" of Instagram). Aggressive media caching vs freshness: immutable long-TTL media URLs make caching trivially effective but mean any change requires a new URL - fine, because edited media gets a new content-addressed URL anyway. Virtualization vs simplicity: windowing bounds memory and DOM but complicates scroll restoration and accessibility and adds variable-height bookkeeping, so it earns its place only on genuinely long feeds. GraphQL vs REST: GraphQL lets the feed card fetch exactly its fields (no over/under-fetch) at the cost of server complexity and caching nuance; a well-shaped REST BFF is a simpler alternative. Naming each trade and committing with a reason - rather than asserting a default - is the senior close.
Summary callouts
Important: Instagram's defining pressure is media (images/video dominate bytes, perf, and memory), so the deep dives are media delivery and caching over a cursor-paginated infinite feed. Architecture: SSR/stream the shell + first page (fast paint, SEO) → CSR infinite scroll; a dedicated image CDN (on-the-fly resize, AVIF/WebP, immutable long-TTL URLs); a normalized client cache (postsById/usersById/feed-as-ordered-ids) as the heart. Data model carries each media's aspectRatio + blurhash (frontend-specific: reserve the box → no CLS; placeholder while loading). API: cursor feed (stable under new posts, fast at depth - not offset), responsive media via srcset/sizes, optimistic like/comment mutations. Media deep dive: responsive right-sized variants + modern formats; aspect-box for zero CLS; blurhash progressive reveal; lazy far / prefetch near;
decoding="async"; video autoplay-on-visible; virtualization frees decoded-image memory. Caching deep dive: distinct layers - CDN/HTTP for media (dominant), client data cache (SWR + dedupe) for feed JSON, service worker for shell + recent media; normalized optimistic mutation reflects a like everywhere at once; scroll restoration for instant back-nav. Scroll: cursor infinite scroll + variable-height virtualization (memory) + scroll restoration. Tradeoffs: SSR vs complexity, cursor vs jumpability, caching vs freshness, virtualization vs simplicity, GraphQL vs REST.
Interview: Lead with the defining pressure and the media journey: "The feed is media-dominated - bytes, perf, and memory are all about images - so I'd architect around media delivery and caching over a cursor-paginated feed. I'd SSR the shell and first page for fast paint and SEO, then client-side infinite scroll. Media comes from a dedicated image CDN with on-the-fly resizing, modern formats, and immutable long-TTL URLs; the client picks the right variant with srcset and sizes. The single most important detail is carrying each image's aspect ratio in the data model so I reserve the layout box before load - zero CLS - and showing a blurhash placeholder while the full image streams in, lazy-loading below the fold and prefetching just-below-fold media. The cache is several distinct layers: CDN for the dominant media payload, a normalized client cache with stale-while-revalidate for feed JSON, and a service worker for the shell. Because the cache is normalized, an optimistic like updates one entity and reflects on the feed, profile, and post page at once. The feed is cursor-paginated so it stays stable as new posts arrive, with variable-height virtualization to bound memory - unmounting off-screen posts frees decoded images - and scroll restoration for instant back-navigation." Routing the whole design through the media journey, naming the distinct cache layers, and justifying cursor + virtualization is the senior answer.
What you should carry out of this case study
Instagram is the canonical media-feed HLD, and its defining pressure is that media - not JSON - dominates the bytes, the perceived performance, and the memory, so the architecture routes everything through the journey of a post's image from CDN edge to a layout-stable, progressively-revealed pixel on a phone. The rendering strategy server-renders the shell and first page for fast paint and SEO and then hands off to client-side cursor-paginated infinite scroll, with the client cache hydrated from the server render rather than refetching. The data model is normalized so a like or edit updates one entity and reflects everywhere, and it carries each media's aspect ratio and a blurhash so the layout box is reserved before load (eliminating layout shift) and a placeholder shows while the image streams. The media deep dive is a stack of techniques - right-sized responsive variants in modern formats from an immutable-URL CDN, lazy loading far from the viewport with prefetch just below the fold, async decoding, and video autoplay on visibility - and the caching deep dive is the recognition that there are distinct layers (CDN for media, a stale-while-revalidate client data cache for feed JSON, a service worker for the shell) each solving a different problem, with normalized optimistic mutations and scroll restoration making interactions and back-navigation instant. The feed uses cursor pagination for stability and depth-performance and variable-height virtualization to bound memory, with unmounting off-screen posts freeing decoded images so the rendering and memory stories converge. The transferable pattern is the feed itself - pagination, caching, normalized entities, optimistic mutations - which the next case studies in the cluster build directly upon.
The next case study takes the feed and adds the pressure Instagram deliberately kept soft: hard realtime. The Facebook/Twitter news feed layers live updates and a ranking surface onto the same infinite-scroll foundation - new posts and changing engagement counts pushed in real time, the merge-into-cache problem at feed scale, and the "new posts available" affordance - combining the feed mechanics here with the real-time machinery from the previous module. The news feed is next.