System Designhard11 min read

Design Instagram (Photo Feed & Stories)

The media-heavy frontend: responsive images that don't shift layout, the Explore grid, and a Stories tray with preloading, progress bars, and gestures.

Published · by Frontend Masters India

Instagram is a photo app, so the interesting frontend problems are about pixels: loading images fast, never letting them shift the layout, packing a grid full of them, and running a Stories player that feels instant. The feed plumbing is the same cursor-and-virtualization story as any feed, so I'll keep that short and spend the time on media, the Explore grid, and Stories.

1. Scope it first

I'd scope to three surfaces: the home feed, the Explore grid, and Stories. Skip DMs, Reels recommendation logic, and the upload editor unless asked.

Questions to ask:

  • Photos only, or video too? Reels and video stories change the player and the autoplay rules.
  • What's the largest image we serve, and on what screens? This sets the responsive image strategy.
  • How fresh are Stories? Stories expire after 24 hours, which affects caching and the "seen" ring.
  • Do we need offline or low-data behavior? Mobile web on a weak connection is a realistic constraint here.

Assume web plus mobile web, photo feed with some video, an Explore grid, and Stories with images and short clips.

2. The feed is mostly an image-loading problem

The list mechanics (cursor pagination, IntersectionObserver to load more, virtualizing variable-height rows) are the standard feed setup. What's specific to Instagram is that nearly every row is a large image, so the loading strategy is most of the work.

Serve the right size. Don't ship a 1080px image to a 400px column. Use srcset and sizes so the browser picks the right file for the viewport and DPR.

<img
  src="/p/abc-640.jpg"
  srcset="/p/abc-320.jpg 320w, /p/abc-640.jpg 640w, /p/abc-1080.jpg 1080w"
  sizes="(max-width: 600px) 100vw, 600px"
  width="1080" height="1350"
  loading="lazy" decoding="async"
  alt="..." />

Reserve space before the pixels arrive. Every image post has a known aspect ratio from the server. Set width/height (or aspect-ratio in CSS) so the box is the right size before the image loads. This is the single biggest lever on cumulative layout shift, and skipping it is the most common mistake.

Show something while it loads. Instagram ships a tiny BlurHash (or a thumbhash) string with each post, a handful of bytes that decode to a blurry preview. Render the blurred placeholder at the reserved size, then cross-fade to the real image on load. It costs almost nothing over the wire and the feed never flashes empty gray boxes.

function FeedImage({ post }) {
  const [loaded, setLoaded] = useState(false);
  return (
    <div style={{ aspectRatio: post.w / post.h }}>
      {!loaded && <Blurhash hash={post.blurhash} />}
      <img
        src={post.url} srcSet={post.srcset} sizes="..."
        loading="lazy" decoding="async"
        onLoad={() => setLoaded(true)}
        style={{ opacity: loaded ? 1 : 0, transition: "opacity .2s" }}
        alt={post.alt} />
    </div>
  );
}

Double-tap to like wants the same optimistic update as any like, plus the heart animation. Keep the animation on the GPU (transform and opacity), and debounce so a fast double-double-tap doesn't toggle the like off.

3. The Explore grid

Explore is a dense grid of thumbnails, sometimes with a mix of square cells and larger 2x2 tiles for video. Three things to get right:

Layout. A CSS grid with grid-auto-flow: dense lets featured tiles span two rows and two columns while the smaller cells flow into the gaps. Squares keep their shape with aspect-ratio: 1.

.explore {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-auto-flow: dense;
  gap: 2px;
}
.explore .feature { grid-row: span 2; grid-column: span 2; }
.explore .cell { aspect-ratio: 1; }

Thumbnails, not full images. Grid cells are small, so request small derivatives. A grid of 30 full-res images would be wasteful. The grid also lazy-loads as you scroll, same IntersectionObserver idea, but the cells are uniform so virtualization is simpler than the feed (fixed cell heights mean you can use a plain windowed grid).

Tap into a viewer. Clicking a cell opens a full post view. On the way in, you already have the thumbnail painted, so animate the thumbnail up to the larger image and swap to the high-res source once it decodes. That makes the transition feel instant even on a slow connection.

4. Stories: the part they're really asking about

Stories is where this prompt earns its difficulty. A Stories tray sits at the top, each avatar with a ring (gradient if unseen, gray if seen). Tapping one opens a full-screen player that auto-advances through that user's stories, then moves to the next user.

The player loop. Each story shows for a fixed duration (about 5s for an image, the clip length for video). A progress bar at the top fills over that duration. When it completes, advance to the next story; at the end of a user, advance to the next user.

function StoryPlayer({ users, startIndex }) {
  const [u, setU] = useState(startIndex);
  const [s, setS] = useState(0);
  function next() {
    const stories = users[u].stories;
    if (s < stories.length - 1) setS(s + 1);
    else if (u < users.length - 1) { setU(u + 1); setS(0); }
    else close();
  }
  // timer drives the active progress bar; calls next() on complete
}

Progress bars. One segment per story in the current user's set. Past segments are full, the active one animates from 0 to 100% over the duration, future ones are empty. Pause the animation when the user holds their finger down (a common Stories gesture) and resume on release. Drive the fill with a CSS transition or requestAnimationFrame, not a chain of setTimeout, so a backgrounded tab doesn't desync the bar from the media.

Preload the next story. This is the detail that separates a real answer. While the current story plays, prefetch the very next one (next story for this user, or first story of the next user) so the swap is instant. Preload one ahead, not the whole tray, to respect data. For images, new Image().src = nextUrl warms the cache; for video, set preload="auto" on a hidden next element or fetch the first segment.

Gestures. Tap right or the right half of the screen advances, tap left goes back, hold pauses, swipe down closes, swipe left/right jumps between users. On the web, map these to click zones and pointer events, and provide keyboard equivalents (arrow keys to move, Escape to close, space to pause) so it isn't touch-only.

The seen ring. Mark a story seen as soon as it's viewed and update the ring from gradient to gray. Persist seen state per story ID so reopening the tray reflects it, and since stories expire in 24 hours you can let that state age out naturally.

5. Memory and teardown

Full-screen media players leak if you're not careful. When the player closes, revoke any object URLs, pause and unload video elements, and clear the preload element so you don't hold a decoded full-screen image in memory. Over a long browsing session this is what keeps mobile web from getting killed by the OS.

What the interviewer will push on

  • "How do you stop the feed from shifting as images load?" Send the aspect ratio with each post and reserve the box before the image arrives. Combined with a BlurHash placeholder, the layout is stable from first paint.
  • "How does the next story feel instant?" Preload exactly one story ahead while the current one plays. The swap reads from cache, not the network.
  • "What happens on a slow connection mid-story?" Pause the progress bar and show a loading state instead of advancing past media the user never saw. The timer is tied to the media being ready, not to a blind clock.
  • "How do you keep the Explore grid cheap?" Request thumbnail derivatives sized to the cell, lazy-load as the user scrolls, and reuse the painted thumbnail as the opening frame of the full viewer.
  • "How do you make Stories accessible?" Keyboard controls for advance/back/pause/close, focus management when the player opens and closes, and alt text or captions on the media. Don't lock the whole feature behind touch gestures.

The one-paragraph recap

Instagram's frontend is dominated by media. The feed serves responsively sized images with reserved aspect-ratio boxes and BlurHash placeholders so layout never shifts, and likes are optimistic with a GPU-driven heart. Explore is a dense CSS grid of thumbnail derivatives that lazy-loads and animates a tapped cell into a full viewer. Stories is the centerpiece: a player that auto-advances with per-story progress bars, preloads exactly one story ahead so swaps feel instant, pauses on hold, supports gestures and keyboard, and updates the seen ring. Tear media down on close to keep mobile web alive. Lead with the layout-shift story and the one-ahead Stories preload, because those are the answers that show you've shipped media before.

Before you leave — how confident are you with this?

Your honest rating shapes when you'll see this again. No grades, no shame.

Comments

to join the discussion.

Loading comments…

More design prompts