cf.completefrontend
My progress
SYSTEM DESIGN · #80

Design Instagram

A photo sharing web app: an image-first feed, stories with a full-screen viewer, posting photos from the browser, likes, comments, and profiles, where the hard part is moving a lot of media fast on phones without layout shift, wasted data, or lost uploads.

The question

Design the web version of Instagram. People scroll a home feed of photos and short videos from accounts they follow, watch stories at the top of the page, post their own photos, like and comment, and visit profile pages.

Focus on the frontend: how the feed loads and renders media, how stories play, how a photo gets from the phone camera roll to everyone else's feed, and how the app stays fast on a mid-range phone on a patchy mobile network.

Real-life examples: Instagram on the web, Instagram mobile web, Pixelfed, VSCO, Glass

Requirements exploration

Instagram is a big product. In 45 minutes you can't design all of it, so the first five minutes are about choosing what to build. A good way to start: say out loud what you think the core is, then ask the questions whose answers would change the design. Questions that don't change anything (what color is the like button?) waste time.

The core of Instagram is a loop: a person posts a photo, their followers see it in the feed or in stories, and they react with likes and comments. Everything else (reels, shopping, direct messages, explore, live video) hangs off that loop. We design the loop and say clearly what we leave out.

Questions to ask, and the answers we'll assume

QuestionAssumed answerWhy it changes the design
Which surfaces are in scope?Home feed, stories tray and viewer, create post, likes, comments, profile grid, notifications.Each surface is a chapter. Reels, DMs, explore, and live are out.
Web only, or also native apps?The web app, used mostly on phones (mobile web) and some on desktop.Mobile web means small screens, touch gestures, weak CPUs, and flaky networks. It also means no native camera APIs: uploads go through a file input.
Photos only, or video too?Photos and short videos in posts and stories. Uploading video is out of scope for the deep dive.Video in the feed means autoplay rules, bandwidth, and decoder limits. Video upload adds transcoding, which is a backend topic.
Is the feed ranked or chronological?Ranked by the server. The client just asks for the next page.Ranking lives on the server. The client must not assume the order is stable between page loads.
Do public profiles and posts need to work for logged-out visitors and link previews?Yes. A shared post link must show a preview in chat apps and be indexable by search engines.That forces server rendering for the public pages, while the logged-in feed can stay client rendered.
How fresh must likes and comment counts be?Within seconds for the person acting, within a minute for everyone else.No WebSocket needed for counts. Optimistic updates for the actor, plain refetching for others.
Do we support offline use?Read what was already loaded; queue likes and posts made offline; no full offline mode.Shapes the service worker and the upload queue.

Functional requirements

  • Home feed: an endless, ranked list of posts from followed accounts. A post has 1 to 10 photos or videos (a carousel), a caption, a like count, and the first comments.
  • Stories: a horizontal tray of avatars at the top of the feed. Tapping one opens a full-screen viewer that plays each story for a few seconds, with progress bars, tap to go forward or back, and swipe to change account.
  • Create post: pick 1 to 10 photos, crop to a supported aspect ratio, optionally apply a filter, write a caption, share. The new post shows up at the top of your own feed and profile right away.
  • Likes and comments: like with a heart button or a double tap on the photo; read and write comments.
  • Profile: header (avatar, counts, bio) and a 3-column grid of posts, loaded page by page.
  • Notifications: a list of likes, comments, and new followers, with an unread badge.

Non-functional requirements, with numbers

Numbers turn vague goals into decisions. These are the ones we'll design against. They're assumptions for the interview, not Instagram's internal figures.

RequirementTargetWhat it pushes us toward
First image on screen (Largest Contentful Paint)At most 2.5 s at the 75th percentile on a mid-range phone on 4GServer-rendered first page of the feed or profile, the first image preloaded, responsive image sizes.
Layout shift (Cumulative Layout Shift)At most 0.1Every media box sized from its aspect ratio before the image loads.
Response to a tap (Interaction to Next Paint)At most 200 msSmall main-thread tasks; image decoding and resizing off the main thread where possible.
Data used per feed screenAround 100 to 200 KB of images per post on a phonePick the image width that matches the screen and device pixel ratio, modern formats (AVIF, WebP).
MemoryStay smooth after scrolling 300+ postsVirtualize or recycle off-screen posts; release video elements.
Upload reliabilityA 12 MB phone photo on a train must not be lostClient-side resize, chunked resumable upload, a persistent upload queue.
AccessibilityWCAG 2.2 AAAlt text on every image, keyboard-usable stories, reduced motion respected.

Out of scope, and why

  • Reels and the explore page: full-screen vertical video is its own design (see the TikTok feed problem). We touch video only where it appears in the home feed.
  • Direct messages: a chat app with its own real-time design (see the chat app guide).
  • Feed ranking and the social graph: server problems. The client only consumes a ranked, paginated list.
  • Video upload and transcoding: we mention where it would plug in, but we deep dive photo upload.
  • Ads and shopping: an ad is just another post type in the feed with extra tracking.

Check your understanding · Exercise 1

Which of these answers would change the frontend design in a real way? Select all that apply.

What makes this hard

On the surface Instagram looks like the news feed problem with bigger pictures. It isn't. Five properties make it its own design, and every later chapter comes back to one of them.

  1. Media is almost the whole payload. A post's JSON is about 2 KB. Its photo is 50 to 300 KB, and a carousel has up to 10 of them. A feed screen is a media problem with a little text attached. Every decision about what to load, at what size, and when, matters more here than in a text feed.
  2. Media has a shape before it has pixels. Photos come in square (1:1), portrait (4:5), and landscape (1.91:1) shapes. If the page doesn't know the shape before the image arrives, everything below it jumps when it loads. That's layout shift, and on a feed it happens on every post.
  3. Time-based UI. Stories play on a timer: a few seconds per photo (this guide uses 5), the length of the clip for a video. The viewer must pause on touch, resume on release, stop when the tab is hidden, and have the next story ready before the timer ends. Timers plus network plus gestures is where bugs live.
  4. Uploads start on the weakest link. A post begins as a 4 to 12 MB photo on a phone, often in HEIC format, often with a rotation flag hidden in its metadata, often on a mobile network. The browser has to shrink it, fix it, and send it without losing it when the train enters a tunnel.
  5. Two audiences. Logged-in users want an app that feels instant. Logged-out visitors, search engines, and link preview bots want plain HTML for a single post or profile. One codebase has to serve both.

Here is the main screen we're designing, with its parts named. We'll refer to these names in the rest of the guide.

Wireframe of the Instagram home feed on a phone. At the top, a bar with the app name. Below it, the stories tray: a row of round avatars, the first two with colored rings for unseen stories. Then a post: a header with the author's avatar and name, a large media box whose height comes from the photo's aspect ratio, an actions row with like, comment, and share, and the caption with a comment preview. A bottom navigation bar has home, search, create, reels, and profile.home feedInstagrammayaphoto, 4:5like comment sharemaya Sunrise over...home search + reels mestories tray, unseen firstpost header: avatar, name, menumedia box: ratio from the APIactions: like, comment, sharecaption and comment preview
Most of the screen is the media box, and its height is known from the API before the photo loads. The stories tray sits above the first post, so it must reserve its height from the first paint.

Some quick math to make the media point concrete. Say a user opens the app and scrolls 30 posts in a session. If we served the stored size (a 1080 pixel wide JPEG, about 250 KB), that's 7.5 MB, plus carousels.

On a phone that's 390 CSS pixels wide with a device pixel ratio (DPR, device pixels per CSS pixel) of 3, the image box is about 1170 device pixels wide, so 1080 is right. On a desktop where the feed column is 470 CSS pixels at ratio 1, a 640 pixel wide image is enough, at about a third of the bytes. In AVIF, the same picture is often 30 to 50 percent smaller again. Over a session, the right size and format can be the difference between 7 MB and 2 MB.

The other quick math is about memory. A decoded image costs width times height times 4 bytes in memory, no matter how small the file was. A 1080 by 1350 portrait photo is about 5.8 MB once decoded, so 100 of them after a long scroll is over half a gigabyte of decoded pixels.

Browsers do discard decoded images that are far off screen, but every image element you keep in the DOM still costs layout and memory, and video elements cost far more. So a long Instagram session needs the same windowing ideas as any long list.

Think first

The news feed guide already covers cursor pagination and virtualization. What's different about virtualizing an Instagram feed compared to a text feed?

The home feed: loading and laying out media

The feed mechanics are the same as any social feed, and the news feed guide covers them in depth. The server returns pages of posts with an opaque cursor (a token that means 'continue after this point'), the client keeps posts in a normalized store keyed by id, and a sentinel element near the bottom triggers the next page through an IntersectionObserver. We use a page size of 10 posts, because each post is heavy.

Read the pagination lesson if cursors are new to you. This chapter is about what's different: the media.

Decision 1: how does a post's media box get its size?

The naive approach is a plain image tag with width: 100% and no height. The browser doesn't know how tall the image is until enough bytes arrive to read its header, so until then the box is 0 pixels tall. When the image arrives the box grows to, say, 587 pixels, and everything below it moves down. The user was about to tap the like button of the next post and taps a photo instead.

On a feed, this happens once per post, and Cumulative Layout Shift (CLS) adds up quickly. A shift's score is the share of the viewport that moved times the distance moved as a share of the viewport height. A 587 pixel jump on an 844 pixel tall screen is a distance of about 0.7, so if most of the screen moves, that one shift scores around 0.5: five times the 0.1 target.

Decision How should the feed reserve space for media before it loads?

Crop everything to a square

Every media box is 1:1. Photos are center-cropped with object-fit: cover.

Pros

  • Trivially stable layout: every box is the same height.
  • Simple grid math.

Cons

  • Cuts off the top and bottom of every portrait photo, which is most phone photos.
  • Users pushed back for years; Instagram began allowing portrait and landscape posts in August 2015.

Measure after load

Render a placeholder of a guessed height, then measure the image when it loads and resize.

Pros

  • Works with any image, no server data needed.

Cons

  • Still shifts, just by a smaller amount.
  • Measuring after load forces extra layouts on the main thread.

Server sends the dimensions, CSS reserves the box

Each media item in the API has width and height (or an aspect ratio).

The client sets aspect-ratio on the box, clamped to the allowed range of 1.91:1 to 4:5, and sets width and height attributes on the image.

Pros

  • Zero layout shift: the box is the right size before any pixel loads.
  • Heights are known for virtualization estimates.

Cons

  • The upload pipeline must record dimensions after orientation is fixed (chapter on posting).
  • Needs a clamp rule for odd shapes, like very tall screenshots.

What we pick: Server sends the dimensions. The upload already knows them, they cost a few bytes, and they fix layout shift completely. Clamp the ratio between 1.91:1 and 4:5 (Instagram's long-standing range for feed posts; it has since begun accepting taller 3:4 photos, which only changes the constant) so one panorama or long screenshot can't make a post 3 screens tall, and let object-fit: cover crop the small overflow. Carousels use the ratio of the first item for every slide, so swiping never changes the post's height.

Two versions of the same post while its photo loads. Left, no dimensions: the image box is 0 pixels tall until bytes arrive, so the like button sits right under the header, then jumps down 587 pixels when the photo loads. That one shift scores a Cumulative Layout Shift of around 0.5. Right, aspect-ratio from the API: the box is the right height from the first paint, filled with the photo's dominant color, and the like button never moves. Layout shift is 0.no dimensions0 px tall until bytes arrivelikejumps 587 pxlikeaspect-ratio reservedaspect-ratio: 4 / 5dominant color firstlikenever movesCLS around 0.5CLS 0
The fix costs a few bytes in the API: width, height, and a color. CSS aspect-ratio turns them into a reserved box before any pixel arrives.

Inside the reserved box, show something better than blank grey while bytes arrive. Two common choices: a dominant color (one hex value from the server, 7 bytes) or a tiny blurred preview (a 20 pixel wide image or a BlurHash string of 20 to 30 characters that the client draws into a small canvas).

The dominant color is free and good enough for most feeds. The blurred preview looks nicer but costs a decode per post. Either way the placeholder lives in the API response, not in a separate request.

Decision 2: which file does the browser download?

The image CDN stores each photo once and serves resized versions on request, for example widths 320, 640, 1080 in AVIF, WebP, and JPEG. The client's job is to ask for the smallest one that still looks sharp. Don't do this in JavaScript by reading window.innerWidth: the browser's preload scanner fetches images before your script runs, and it already knows the viewport and pixel ratio. Give it the choices and let it pick.

tsx
function FeedImage({ media, isFirst }: { media: Media; isFirst: boolean }) {
  const ratio = clamp(media.width / media.height, 4 / 5, 1.91);
  const widths = [320, 640, 1080];
  const srcSet = (fmt: string) =>
    widths.map((w) => `${cdn(media.key, w, fmt)} ${w}w`).join(", ");
  return (
    <div className="media-box" style={{ aspectRatio: ratio, background: media.color }}>
      <picture>
        <source type="image/avif" srcSet={srcSet("avif")} sizes="(min-width: 640px) 470px, 100vw" />
        <source type="image/webp" srcSet={srcSet("webp")} sizes="(min-width: 640px) 470px, 100vw" />
        <img
          src={cdn(media.key, 640, "jpg")}
          srcSet={srcSet("jpg")}
          sizes="(min-width: 640px) 470px, 100vw"
          width={media.width}
          height={media.height}
          alt={media.alt}
          loading={isFirst ? "eager" : "lazy"}
          fetchPriority={isFirst ? "high" : "auto"}
          decoding="async"
        />
      </picture>
    </div>
  );
}
A feed image: reserved box, responsive sizes, modern formats, and the right priority.
  • sizes tells the browser how wide the image will be drawn: the full viewport on phones, a 470 pixel column on wider screens. With srcset widths, it picks the smallest file at least that wide times the device pixel ratio.
  • The first post's image is the Largest Contentful Paint element. It gets fetchpriority="high" and is never lazy. Lazy loading the LCP image is one of the most common causes of a slow LCP.
  • Everything else gets loading="lazy". Browsers start lazy images some distance before they enter the viewport (web.dev documents Chrome's distance as 1250 pixels on fast connections and 2500 pixels on slow ones), so the user rarely sees a placeholder at normal scroll speed.
  • decoding="async" lets the browser decode off the critical path, which helps scrolling stay smooth on big photos.

Keeping a long session light

After 200 posts the DOM holds thousands of nodes, a few dozen video elements, and large decoded images. Use the virtualization lesson's approach: render only the posts within about two screens of the viewport and replace the rest with spacer heights. Because media heights come from the API, the estimated height of an unrendered post is close to exact (media height plus a measured average for header, actions, and caption), so the scrollbar stays honest.

Two Instagram-specific details. First, when a post leaves the window, unmount its video completely and clear the src so the browser can free the decoder; a paused video element still holds memory and sometimes network connections. Second, many teams virtualize only after the feed grows past a threshold (say 50 posts): the first screens stay plain DOM, which keeps find-in-page, anchor links, and screen reader browsing simple for the common short session.

Prefetching the next items

Two kinds of prefetch keep the feed ahead of the thumb. Data prefetch: request the next page when the user is 3 posts from the end, not at the end, so the JSON is there before the sentinel is visible. Media prefetch: lazy loading already fetches images a bit ahead, but not for carousels, because slides 2 to 10 are off screen to the side.

So fetch slide 2 of a carousel once the post is visible, and slide n+1 when the user lands on slide n. Don't prefetch all 10: most people never swipe past the second slide.

Respect the user's data budget. If navigator.connection.saveData is true (Chromium only, so treat it as a hint) or the user picked a data saver setting, drop the 1080 width from srcset, skip carousel prefetch, and don't autoplay video. Read the prefetching lesson for the general rules on when prefetching costs more than it saves.

The feed as a column of posts while scrolling. Posts 1 and 2 have scrolled far away and are unmounted, replaced by a spacer of known height. Post 3 is rendered above the viewport. Posts 4 and 5 are in the viewport and are the autoplay candidates. Posts 6 and 7 are rendered below, and their lazy images are already loading. Post 8 is three from the end, so seeing it triggers the request for page 2, the next 10 posts, which is drawn dashed below.post 1post 2post 3post 4post 5post 6post 7post 8page 2unmounted: a spacer of known heightrendered, above the viewportviewport: autoplay candidatesrendered below, lazy images loading3 from the end: fetch page 2 nowGET /feed?cursor=...
Heights come from the API, so the spacer for unmounted posts is almost exact. The next page is requested 3 posts early, so its JSON arrives before the user reaches the end.

Check your understanding · Exercise 2

A user on a phone 390 CSS pixels wide with a device pixel ratio of 3 opens the feed. Which statements are true? Select all that apply.

Video in the feed: autoplay only when visible

Some feed posts are short videos. Instagram plays them muted as soon as they're mostly on screen, and pauses them when they scroll away. That sounds like one line of code, but it touches browser autoplay rules, battery, data, decoder limits, and accessibility. Playback itself (adaptive streaming, buffering, the player controls) is covered in the YouTube guide; here we only decide when a feed video plays.

Start from the browser rules. Every major browser lets a muted video autoplay without a user gesture; on iOS Safari it must also play inline, with the playsinline attribute, or it tries to go full screen. Autoplay with sound is decided per browser: Chrome allows it after the user has interacted with the site, or when the site's Media Engagement Index (Chrome's per-site score of how often you watch media with sound there) is high, while Safari and Firefox block audible autoplay by default.

Even a muted video.play() returns a promise that can reject, for example in iOS Low Power Mode or when the user has turned autoplay off for the site. So autoplay is a request, not a guarantee, and the UI must handle the rejected promise by showing a play button.

Decision Which videos should play, and who decides?

Native autoplay attribute

Every feed video has autoplay muted loop playsinline.

Pros

  • No JavaScript.

Cons

  • Videos play even when off screen, wasting data and battery.
  • Several can play at once when two short posts are visible.
  • No hook for reduced motion or data saver.

Each post observes itself

Each video has its own IntersectionObserver and plays when 60 percent or more is visible.

Pros

  • Simple and local to the component.

Cons

  • Two half-visible videos can both cross 60 percent on a tall screen and play together.
  • No global view to enforce one video at a time.

One playback coordinator

One shared IntersectionObserver with thresholds at 0, 0.6, and 1 reports visibility ratios to a small store.

The coordinator picks a single active video: the most visible one above 60 percent, tie broken by closeness to the viewport center.

It calls play() on the active one and pause() on the previous one.

Pros

  • Exactly one video plays.
  • One place to apply rules: reduced motion, data saver, hidden tab, user pressed pause.

Cons

  • A bit more code, and posts must register and unregister their video elements.

What we pick: One playback coordinator. It is the only option that guarantees one video at a time and gives a single place for the rules that matter on phones. It also makes testing easy: feed it fake visibility ratios and assert which video id is active.

The life of a feed video

Each feed video moves through a few states. Writing them down as a state machine stops the classic bugs: a video that resumes after the user paused it, or one that keeps playing after the tab is hidden.

State machine for a feed video. Idle, a poster with preload none, moves to ready, preload metadata, when the post is within one screen. Ready moves to playing, muted, inline, and looping, when the coordinator picks it. If play() rejects, playing moves to blocked, where only a tap starts playback. Scrolling away or hiding the tab moves playing to paused by the system, which resumes when back in view. A user tap on pause moves playing to paused by the user, and only a tap resumes it.idleposter, preload nonereadypreload metadataplayingmuted, inline, loopblockedtap starts playpaused (system)auto-resumespaused (user)only a tap resumeswithin 1 screenpickedplay() rejectsscrolled away, hiddenin viewuser pauses
Two paused states, not one. A video the system paused comes back by itself; a video the user paused waits for the user. Mixing them up is the most common autoplay bug.
  • idle: the post is rendered but the video has only a poster image and preload="none". No video bytes are fetched yet.
  • ready: the post is within one screen of the viewport. Set preload="metadata" (or attach the stream) so the first frame is quick when it becomes active.
  • playing: the coordinator picked it. Muted, looping, playsinline.
  • paused-by-system: scrolled away, tab hidden (visibilitychange), or another video took over. Resume automatically when the reason goes away.
  • paused-by-user: the user tapped pause. Never auto-resume from here, even if the video becomes the most visible again. This is the state the naive version forgets.
  • blocked: play() rejected. Show the poster with a play button; a tap counts as a user gesture and can start playback, with sound if the user unmutes.
ts
function pickActive(ratios: Map<string, number>, centerDist: Map<string, number>) {
  let best: string | null = null;
  for (const [id, r] of ratios) {
    if (r < 0.6 || userPaused.has(id)) continue;
    if (!best || r > ratios.get(best)! ||
        (r === ratios.get(best) && centerDist.get(id)! < centerDist.get(best)!)) best = id;
  }
  return best;
}

function canAutoplay() {
  if (document.visibilityState === "hidden") return false;
  if (matchMedia("(prefers-reduced-motion: reduce)").matches) return false;
  if (settings.dataSaver) return false;
  return true;
}
The coordinator's choice and its guard rails.

Unmuting is sticky for the session: when the user taps the speaker on one video, the next active video should start with sound too. Chrome allows that once the user has interacted with the page, but Safari and Firefox can be stricter, so if play() with sound rejects, retry once muted and show the speaker icon as off. Store the choice in the coordinator, not in the post, or it resets every time a post is recycled by virtualization.

Check your understanding · Exercise 3

Which behaviors are correct for feed video autoplay? Select all that apply.

Stories: the tray and the full-screen viewer

Stories are photos and short clips that disappear after 24 hours. They have two parts: a tray of round avatars at the top of the feed, and a viewer that opens full screen and plays one account's stories in a row, then moves to the next account. A full component-level design of the viewer is its own problem (the stories viewer problem); here we design what the Instagram app needs from it and how it fits with the rest.

Wireframe of stories. Left, the tray: round avatar buttons for you, maya, leo, and sam; maya and leo have colored rings for unseen stories. Tapping maya opens the viewer on the right, full screen: three progress bars at the top, the first full and the second half full; the name maya and the time; a photo area split into a back zone on the left third and a forward zone on the rest, where holding pauses; and a reply field at the bottom.stories trayyoumayaleosamring: unseen, grey: seeneach avatar is a buttonmore load as you scrollviewer code preloadsviewer, full screenmaya 2hclosebackforwardhold anywhere: pausereply to mayasend
The tray holds only avatars and unseen flags. The viewer's tap zones are uneven on purpose: going forward is far more common than going back, so it gets two thirds of the width.

The tray

The tray loads with the feed's first request, or right after it, as a list of accounts with an unseen flag and an avatar URL. It doesn't include the stories themselves. A user may follow 300 accounts with active stories; fetching every story item up front would be hundreds of requests and megabytes for a tray the user may never tap. The server orders it (unseen first, then by closeness), and the client shows a colored ring for unseen and a grey ring for seen.

The tray is a horizontal list that scrolls with touch and has previous and next buttons on desktop. Render the first 10 to 15 avatars and load more as the user scrolls sideways, using the same sentinel pattern as the feed. Each avatar is a real button with an accessible name like 'Story by maya, not seen', so screen reader users know which ones are new.

The viewer: one clock, many inputs

The viewer shows a row of thin progress bars at the top, one per story in the current account. The active bar fills over a fixed time for a photo, or over the clip's length for a video. The exact photo duration is a product choice; this guide uses 5 seconds. At the end it moves to the next story, and after the last story of an account it moves to the next account in the tray order.

Many inputs change that clock: a tap on the right side skips forward, a tap on the left third goes back, pressing and holding pauses, a horizontal swipe jumps to the next or previous account, a swipe down closes. Plus things the user doesn't do: the image hasn't loaded yet, the video is buffering, the tab went into the background.

Decision How should the viewer keep time for the active story?

setTimeout per story

Start a 5 second setTimeout when a story opens; advance when it fires. The bar is a CSS transition.

Pros

  • Tiny amount of code.

Cons

  • Pausing needs clearing and restarting the timer with the remaining time, which you must track yourself anyway.
  • The CSS transition and the timer drift apart, so the bar finishes before or after the advance.
  • Background tabs throttle timers, and the timer doesn't know the image is still loading.

CSS animation drives everything

The bar has a 5 second CSS animation. Pausing sets animation-play-state: paused. The animationend event advances.

Pros

  • Smooth, runs on the compositor.
  • Pause and resume are one property.

Cons

  • Video duration isn't known until metadata loads, and buffering must pause the animation too.
  • Hard to test and to read the current progress for analytics.

One elapsed-time clock in the viewer state

State holds elapsedMs, durationMs, and a set of pause reasons (holding, loading, buffering, hidden, dialog open).

A requestAnimationFrame loop adds the frame delta to elapsedMs only when the pause set is empty, writes the progress as a transform: scaleX(...) on the bar, and advances at the end.

For video, the clock follows video.currentTime instead of counting frames.

Pros

  • One source of truth for progress, pausing, and advancing.
  • Pause reasons compose: releasing a hold doesn't resume a story that's still loading.
  • Deterministic in tests with a fake clock.

Cons

  • More code than a timeout.
  • Must stop the loop when the viewer closes.

What we pick: One elapsed-time clock. The viewer has too many reasons to pause for a timer or a CSS animation to stay correct. A set of pause reasons is the key idea: the story runs only when the set is empty, so a hold released during buffering can't resume early. requestAnimationFrame stops in background tabs, which pauses the story for free (cap each frame's delta at about 100 ms, so the first frame after a long gap doesn't jump ahead), and the visibilitychange event adds a 'hidden' reason so video stops too.

Try it below. Tap through the stories, hold to pause, and watch what the preloader has ready. Then turn preloading off and see where the viewer has to wait.

Network
Story viewer at 0 s. Showing maya 1, 0 of 5 s. Pause reasons: loading. Time spent waiting for media: 0 s.maya: progress barsmaya 1waitingmaya 2loadingmaya 3 vnot startedleo 1loadingleo 2not startedsam 1not startedclock: 0 of 5 spaused by: loadingwaited for media: 0 sseen marks to send: 0
Press +0.5 s to run the clock. Try holding, tapping, and turning preloading off. maya 3 (v) is a 5 second video clip, so it needs more buffering.

Preloading the next story

The rule is simple: the next thing the user will see must be decoded before the current story ends. For photo stories, create the next image with new Image(), set its src, and call img.decode(), which resolves when the image is ready to paint without a flash. For video, attach the next clip with preload="auto" or fetch its first few seconds.

Preload two things: the next story in this account, and the first story of the next account, because a swipe jumps there. Don't preload further. Many users close the viewer after a few stories, and each prefetched story costs 100 KB to 2 MB.

Marking stories as seen

A story counts as seen when it's been on screen, not when it was preloaded. The client adds the story id to a local seen set when its clock starts, which immediately turns the tray ring grey (an optimistic update). It sends seen ids to the server in batches: every 5 seconds, when the viewer closes, and when the page is hidden.

The last case uses fetch with keepalive: true (or navigator.sendBeacon), so the request survives the tab being closed. The server treats seen marks as a set, so sending an id twice is harmless.

Gestures and the URL

  • Use Pointer Events for taps and holds: pointerdown starts a 200 ms hold timer; pointerup before it fires is a tap (forward or back by which side), after it is a release from hold.
  • Set touch-action: none on the story area, because the viewer handles horizontal swipes and swipe down itself and the browser must not scroll or zoom. Decide the swipe direction after the first 10 pixels of movement, then ignore the other axis.
  • The viewer is a modal dialog with its own URL, like /stories/maya/3141. Opening it pushes a history entry, so the phone's back gesture closes it instead of leaving the app. Closing returns focus to the avatar that opened it.
  • Keyboard: right and left arrows move between stories, Escape closes, Space toggles pause. Each story's image has alt text, and a live region announces 'Story 2 of 5 by maya' on change, without announcing every progress tick.

Check your understanding · Exercise 4

The user presses and holds on a photo story while it is still loading, then releases. The photo finishes loading 1 second later. What should the viewer do? Select all that apply.

Creating a post: from camera roll to a clean upload

Posting is where the browser does real work. The user picks up to 10 photos straight from a phone camera: each 3 to 12 MB, often HEIC on iPhones, 4000 pixels wide or more, with a rotation flag in the EXIF metadata and sometimes GPS coordinates. The app needs 1080 pixel wide JPEGs of about 200 to 400 KB each, correctly rotated, cropped to an allowed aspect ratio, maybe with a filter, and with location data removed unless the user chose to share it.

Picking files

Use a real file input: input type=file with accept="image/*,video/*" and multiple. On phones this opens the system picker with the camera roll and the camera, and on desktop the composer also accepts drag and drop. Check the count (10 at most) and the type right away, before reading any bytes, and tell the user which files were skipped and why. The file uploads lesson covers the input, drag and drop, and validation in general.

Decision: where do we shrink the photo?

The naive approach uploads the original file and lets the server resize it. On Wi-Fi that's fine. On a mobile network at an effective 1.5 Mbps upload, 10 photos of 6 MB each is 60 MB, which is over 5 minutes of uploading, most of it for pixels the server throws away. And if the connection drops at 80 percent, a non-resumable upload starts over.

Decision Where should photos be resized and re-encoded?

Server only

Upload the original; the server decodes, rotates, resizes, and strips metadata.

Pros

  • Simplest client.
  • One consistent pipeline, handles every format the server supports.

Cons

  • Uploads 10 to 30 times more bytes than needed.
  • Slow and fragile on mobile networks; uses the user's data plan.
  • The user waits to see the final crop and filter.

Client resizes on the main thread

Load the file into an image element, draw it to a canvas at 1080 wide, export with canvas.toBlob.

Pros

  • Small uploads.
  • The preview is exactly what will be posted.

Cons

  • Decoding a 12 megapixel photo and encoding it can block the main thread for hundreds of milliseconds on a mid-range phone, freezing the composer.

Client resizes in a worker, server re-checks

A Web Worker decodes with createImageBitmap(file), draws to an OffscreenCanvas at the target size, applies the filter, and encodes with convertToBlob.

The server still validates, re-encodes into its CDN formats, and strips metadata, so a malicious or broken client can't publish bad files.

Pros

  • Small uploads and a responsive UI.
  • Work starts the moment a photo is picked, in parallel with the user typing.

Cons

  • Format support varies by browser (HEIC especially).
  • More moving parts: a worker, a fallback path, memory limits.

What we pick: Resize in a worker, and keep the server as the final authority. The client shrink is an optimization for speed and data; the server re-encode is for safety and consistency. If anything fails in the worker (unsupported format, out of memory), fall back to uploading the original and let the server do it.

The client pipeline, step by step

  1. Decode. createImageBitmap(file, { imageOrientation: "from-image" }) decodes the file off the main thread and applies the EXIF rotation, so a portrait photo isn't drawn sideways. from-image is the default in today's spec, and since 2020 browsers also honor EXIF orientation by default when showing images (CSS image-orientation: from-image). Older versions differed, so being explicit costs nothing.
  2. Measure and crop. Read the bitmap's width and height after rotation. Clamp the crop to the allowed range of 1.91:1 to 4:5, using the crop box the user set in the editor. These rotated dimensions are what we send to the server and what the feed later uses to reserve space.
  3. Resize. Draw the cropped area onto an OffscreenCanvas whose width is at most 1080 pixels. For big reductions, halving in two or three steps or setting imageSmoothingQuality = "high" avoids a jagged result.
  4. Filter. Apply the chosen filter while drawing (next section).
  5. Encode. canvas.convertToBlob({ type: "image/jpeg", quality: 0.85 }). JPEG is the safest choice, because every server and every browser can read it, and every browser can encode it. Ask for WebP and Safari silently returns a PNG instead (the spec's fallback), so always check blob.type. Re-encoding through a canvas also drops the EXIF block, which removes GPS data as a side effect.
  6. Hand off. Post the resulting blob back to the main thread for the preview and to the upload queue. Close the bitmap with bitmap.close() to free its memory right away.
PhotoBrowser
Network
Upload pipeline, step 1 of 8: pick. IMG_2041.HEIC. 4032 x 3024, 2.8 MB. Bytes sent: 2867 KB instead of 2867 KB.pickdecoderotatecropresizeencodeuploadcreatenowIMG_2041.HEIC4032 x 3024, 2.8 MBoriginal: 2867 KBsent: 2867 KB
Step 1, pick: IMG_2041.HEIC, 4032 x 3024, 2.8 MB.

HEIC and other awkward formats

iPhones save photos as HEIC by default, and whether a browser can decode HEIC is the weak point of the pipeline. Safari can decode HEIC on Apple platforms; Chrome and Firefox generally can't. Two things soften this.

First, when the accept attribute doesn't list image/heic (ours, image/*,video/*, doesn't), iOS Safari usually converts a photo picked from the library to JPEG before the page sees it. Listing image/heic changes that and can hand you HEIC files, so leave it out unless your pipeline wants them. Second, when a HEIC file does arrive (desktop drag and drop, Android with HEIF files), detect the failure: createImageBitmap rejects.

Then choose a fallback: decode with a WebAssembly HEIC decoder loaded only when needed (it's large, often over 1 MB), or upload the original and let the server convert it. The server path is the simpler default; the WebAssembly path is worth it only if HEIC uploads are common in your analytics.

Filters

A filter is a color transform: warmer, cooler, more contrast, faded blacks. The 2D canvas has a filter property that takes CSS filter functions like contrast(1.1) saturate(1.2) sepia(0.15), applied while drawing. It's fast and simple, but Safari still keeps it behind a flag, so feature-detect it, and it can't express every look.

Instagram-style looks are usually done with a color lookup table (a LUT): a small image that maps each input color to an output color, applied per pixel in a WebGL fragment shader. WebGL makes this fast even on a 1080 by 1350 image.

For the live preview, apply a matching CSS filter (or the same shader) to the preview image, and only bake the look into pixels at export time. Make sure the preview and the export use the same numbers, or users will see a different photo after posting.

A carousel post holds up to 10 items in an order the user can change by dragging thumbnails (with keyboard alternatives: move left and move right buttons). All items share the first item's aspect ratio, so the composer crops each to that ratio.

Process items through the worker one or two at a time, not all 10 at once: decoding ten 12 megapixel photos in parallel needs over 400 MB of memory and can crash a phone's tab. Process the first item first, so its preview appears quickly, then the rest in the order the user sees them.

Check your understanding · Exercise 5

Which steps belong in the client-side photo pipeline? Select all that apply.

Uploading reliably, in the background, with an optimistic post

The photos are now small and clean. The next question is how they get to the server without making the user wait and without getting lost. The naive flow is one big request on the Share button: caption plus 10 files in a multipart form.

The user taps Share, watches a spinner for 20 seconds on a slow network, and if the connection drops at 90 percent, gets an error and has to start over. Worse, if they leave the page during the spinner, the post is gone.

Split media from the post

The key move is to separate uploading media from creating the post. Each photo uploads on its own as soon as it's processed, and the server returns a mediaId. Creating the post is a tiny JSON request: caption, the ordered list of media ids, alt texts, location, and a client-generated idempotency key. By the time the user has finished typing a caption (usually 10 to 30 seconds), most photos are already on the server, and Share feels instant.

Decision How should each media file be sent?

One request per file through our API

POST /media with the file body; the API server stores it.

Pros

  • Simple; auth and validation in one place.

Cons

  • All media bytes flow through API servers.
  • A dropped connection restarts the whole file.

Direct to storage with a signed URL

Ask the API for a short-lived signed upload URL, then PUT the file straight to object storage.

Pros

  • API servers never see the bytes.
  • Object storage scales and is close to the user.

Cons

  • Still restarts from zero if the connection drops mid-file, unless the storage supports multipart or resumable sessions.

Resumable upload session

Create an upload session, then send the file in chunks (for example 1 MB) with their byte offsets.

After a failure, ask the server how many bytes it has and continue from there. Protocols like tus, and the resumable upload modes of the big cloud storage services, work this way.

Chunk rules differ: tus accepts any size, Google Cloud Storage wants multiples of 256 KiB, and S3 multipart needs parts of at least 5 MiB, so S3 multipart doesn't fit files of a few megabytes.

Pros

  • Survives drops, app switches, and page reloads if the session id is saved.
  • Natural progress reporting per chunk.

Cons

  • More requests and more code.
  • Overkill for a 300 KB file that would finish in one round trip.

What we pick: Use a resumable session with a size switch. A resized photo of about 300 KB goes in a single chunk: if it fails, resending 300 KB is cheap. The original-file fallback (several MB) and any video use real chunks and resume. Either way, the client gets a signed URL from the API so bytes go straight to storage. Read the file uploads lesson for chunk sizes and resume handshakes in detail.

Each media item is a state machine

Every picked photo moves through the same states, and the composer shows them on its thumbnail: a progress ring while uploading, a retry badge on failure, a check mark when done.

State machine for one media upload. Processing, the worker resize, moves to queued when the blob is ready. Queued moves to uploading when a slot is free. Uploading sends chunks with progress and moves to done, with a media id, once all chunks are sent. On a network error or 5xx, uploading moves to retry wait, with backoff of 1, 2, then 4 seconds and longer, and back to uploading when the timer fires. After 5 tries, retry wait moves to failed, which shows a retry button that puts the item back in the queue. Going offline moves uploading to paused; coming back online asks the server for the offset and resumes uploading.processingworker resizequeuedwaits for a slotuploadingchunks, progressdonehas a mediaIdretry waitbackoff 1, 2, 4 s...failedretry: queuedpausedofflinereadyslotall senterror, 5xxtimer fires5 triesofflineonline: ask the offset
Paused and retry wait are different on purpose: offline waits for the online event without burning tries, while errors back off and give up after 5. Removing a photo from any state aborts its request.
  • Progress: fetch has no upload progress events, so use XMLHttpRequest and its upload.onprogress for the bytes of the current chunk, plus the count of completed chunks for the whole file.
  • Retries: on a network error or a 5xx, retry with exponential backoff and jitter (1 s, 2 s, 4 s, and so on, capped at 30 s). After about 5 tries, mark the item failed and show a retry button. On a 4xx like 'file too large', fail at once with the server's message; retrying won't help.
  • Offline: when navigator.onLine turns false, pause the queue instead of burning retries, and resume on the online event. Treat onLine as a hint, because it only knows about the local link, not whether the server is reachable.
  • Removal: if the user removes a photo from the composer, abort its request with an AbortController (or xhr.abort()) and tell the server to drop the session.
  • Concurrency: upload 2 items at a time. More doesn't help on a phone's uplink and starves the feed's own requests.

Share: the optimistic post

When the user taps Share, close the composer at once and insert a pending post at the top of their home feed and profile grid, built from the local previews (blob URLs) and the caption. It shows a thin progress line and a 'Posting...' label. Behind it, the upload queue finishes any remaining media, then sends the create-post request with the idempotency key.

When the server replies with the real post, swap the pending entry for it, keeping the same position. If it fails after retries, keep the pending post on screen with 'Couldn't post. Retry or Discard', and never throw away the user's photos and caption silently.

Sequence diagram for sharing a post, with five columns: User, Composer, Queue, API, and Feed. The user picks 3 photos; the composer enqueues the resized blobs; the queue uploads them while the user writes. The user taps Share; the composer inserts a pending post into the feed. The queue sends POST /posts with an idempotency key, k1. The reply is lost to a timeout. The queue retries with the same key k1, and the API answers 201 with the post it already created. The queue swaps the pending post for the real one in the feed.UserComposerQueueAPIFeedpick 3 photosenqueue blobsupload m1-m3tap Sharepending postPOST, key k1reply lostretry, key k1201, same postswap for real
Media upload starts at pick time, so Share only sends a small JSON request. The retry is safe because it carries the same idempotency key: the server returns the post it already made instead of a duplicate.

The idempotency key matters on bad networks. Suppose the create-post request reaches the server and the post is created, but the response is lost in a tunnel, so the client retries. Without a key, the user ends up with two identical posts; with the key, the server sees it has already handled this key and returns the existing post. Generate it once with crypto.randomUUID() when the user taps Share, and store it with the draft.

Surviving a closed tab

Keep the draft and the queue in IndexedDB: the processed blobs, the caption, each item's upload session id and state, and the idempotency key. If the user closes the tab, or the phone kills it in the background, the next app open finds the pending post and resumes it, asking the server how many bytes it has for each session.

In Chromium browsers, a service worker can also use Background Sync to finish the create-post request after the page is gone. Safari and Firefox don't support it, so treat it as a bonus on top of resume-on-next-open, not the main mechanism. The offline-first and background sync lessons go deeper on both.

Check your understanding · Exercise 6

The create-post request times out on a train. The client retries and gets a 200 with a post id. What made this safe, and what should the client do? Select all that apply.

Likes, comments, and notifications

Likes are the most frequent write in the app, often several per minute while scrolling. They must feel instant, survive bad networks, and stay consistent wherever the same post appears: the feed, the profile grid's post view, and a post page opened from a notification.

The double tap

Double tapping a photo likes it, with a big heart that pops over the photo. Three details make it work on the web. First, mobile browsers use double tap to zoom; setting touch-action: manipulation on the media box turns that off for the box (pinch zoom still works), and it also removes the old tap delay.

Second, don't rely on the dblclick event alone: detect two pointerup events within about 300 ms and 30 pixels of each other yourself, so it works the same for touch, pen, and mouse. Third, double tap only ever likes; it never unlikes. Users double tap absent-mindedly, and an accidental unlike feels like a bug, so the heart button is the only way to unlike.

A single tap on the photo is also meaningful (it toggles sound on a video, or shows tagged people). That creates a conflict: you can't know a tap is single until the double-tap window passes. Delay the single-tap action by 300 ms, and cancel it if a second tap arrives. The like itself fires on the second tap with no delay.

Decision: when does the heart turn red?

Decision How should the like button update?

Wait for the server

Send the request, show a spinner or disable the button, update on success.

Pros

  • The screen never shows a state the server doesn't have.

Cons

  • 300 ms to 2 s of lag on mobile for the most common action.
  • Feels broken on a train.

Optimistic, one request per tap

Flip the heart and the count at once, send the request, revert on failure.

Pros

  • Instant feedback.

Cons

  • Like, unlike, like in one second sends 3 requests that can arrive out of order, and the final server state may not match the screen.

Optimistic, coalesced to the latest intent

Flip at once. Record the desired state per post, and send it after a short pause (about 300 ms) or immediately if nothing is in flight.

Requests set state (PUT for liked, DELETE for not liked), never toggle, so they're idempotent.

When a response arrives, if the desired state has changed meanwhile, send the new one. On final failure, revert to the last state the server confirmed.

Pros

  • Instant, and the server ends up matching the last thing the user did.
  • Rapid tapping costs one or two requests.

Cons

  • A little more state per post: confirmed, desired, in flight.

What we pick: Optimistic and coalesced. Likes fail rarely, and when they do the cost of a brief wrong heart is tiny, so optimism is right. Sending 'set liked to true' instead of 'toggle' is what makes retries and out-of-order replies safe. The optimistic updates lesson covers the rollback pattern in general.

Sequence diagram for likes, with four columns: User, PostCard, Store, and API. Happy path: the user double taps; the card calls setLiked true on the store; the store re-renders the card with a red heart and 1,205 likes at once; the store sends PUT /posts/3141/like; the API replies 200 with likeCount 1,205. Failure path: the user taps the heart to unlike; the card calls setLiked false; the store flips it and sends DELETE /posts/3141/like; the API answers 503 even after retries; the store rolls back to liked and the card shows a red heart again with a short error message.UserPostCardStoreAPIdouble tapsetLiked(true)red heart, 1,205PUT /posts/3141/like200, likeCount 1,205failure pathtap heart: unlikesetLiked(false)DELETE .../like503 after retriesback to red, toast
The screen changes before the network call starts. The request sets a state (PUT or DELETE), never toggles, so a retry can't flip it the wrong way, and a final failure rolls back to the last state the server confirmed.

The like lives in the normalized post entity, not in the feed item component. That's why it shows correctly everywhere: the feed, the post page, and the profile all read posts[id].viewerHasLiked and posts[id].likeCount. When a fresh page of data arrives from the server, merge carefully: if a like for that post is still in flight, keep the local desired state instead of overwriting it with the server's older value.

Display counts with Intl.NumberFormat and notation: "compact", so 15,300 shows as '15K' in English and the right form in other locales. Exact counts for big accounts change every second, and nobody needs them exact.

Comments

The feed shows the comment count and at most one or two preview comments, which come inside the feed response. Tapping 'View all 248 comments' opens the comment sheet (a bottom sheet on phones, a side panel in the post dialog on desktop), which fetches comments by cursor, 20 at a time, newest or most relevant first as the server decides.

Replies are folded under their parent with 'View 12 replies', and fetched only when expanded. One level of nesting is enough; deeper trees are hard to read on a phone.

  • Posting a comment is optimistic too: append it with a 'Posting...' state and a client id, then replace it with the server's comment. On failure, keep the text and offer retry, because comments take effort to write.
  • Text safety: render comments and captions as text, never as HTML. Mentions and hashtags are parsed into tokens on the server (or by a small client parser) and rendered as links. That avoids XSS from user content.
  • New comments from others don't stream in live. When the sheet opens it fetches fresh; a 'New comments' pill appears if a background refetch every 30 seconds while the sheet is open finds more. Live comments are a different problem, for live video.

Notifications

The activity screen lists likes, comments, mentions, and new followers, grouped ('maya and 12 others liked your photo'), with a cursor for older items. The heart icon in the nav shows an unread badge. Keeping that badge fresh is the only real-time need, and it's a small one.

ApproachWhen it fitsCost
Poll the unread count every 60 s while the page is visibleThe default. A one-number response, paused when the tab is hidden.One tiny request a minute per active user.
Server-Sent Events or a WebSocketIf the app already has a socket (for direct messages), send badge events over it.A long-lived connection per user; not worth adding just for a badge.
Web PushTo reach users when the site is closed. Needs permission, a service worker, and on iOS the site must be added to the home screen.Permission prompts annoy users; ask only after a clear action, like turning on post notifications.

Opening the activity screen marks items as seen up to the newest one shown, with one request that sends the newest id. Also sync the badge across tabs with a BroadcastChannel, so reading notifications in one tab clears the badge in the others.

Check your understanding · Exercise 7

A user taps the heart three times quickly (like, unlike, like) on a slow network. With the coalesced optimistic design, which statements are true? Select all that apply.

The profile grid and server rendering for public pages

The profile grid

A profile page has a header (avatar, name, post and follower counts, bio, follow button) and a grid of the account's posts, three columns wide on every screen size. The grid is the classic Instagram look and it's simpler than the Pinterest masonry: every tile in the grid has the same shape, so rows have a fixed height and there's nothing to balance. Instagram used squares for years and switched the profile grid to taller 3:4 tiles in January 2025; either way, the shape is one constant, not a per-image value.

  • Layout: CSS grid with grid-template-columns: repeat(3, 1fr), a 2 to 4 pixel gap, and aspect-ratio on each tile. No JavaScript layout at all.
  • Thumbnails: the server makes a cropped thumbnail for each post (for carousels, from the first item). Tiles are about a third of the screen: 130 CSS pixels on a phone, around 300 on desktop. Use srcset with 320 and 640 widths and sizes="33vw" (capped on desktop), so a phone downloads 20 to 40 KB per tile, not the full post image.
  • Badges: an icon for carousels and videos in the corner, with the same meaning in the tile's accessible name ('Carousel post by maya, 4 photos').
  • Pagination: 12 posts per page (4 full rows), loaded by cursor when a sentinel 2 rows from the bottom becomes visible. Always load in multiples of 3 so the last row isn't ragged between pages.
  • Long profiles: an account with 5,000 posts has 1,667 rows. Every row has the same known height (the column width times the tile's height-to-width ratio), so virtualizing by row is easy and exact. Start virtualizing after a few hundred posts.

Tapping a tile opens the post. On desktop it opens as a dialog over the grid, with its own URL (/p/ plus a short code), so the URL can be shared and the back button closes the dialog. On a phone it navigates to the post page, and loading the URL directly (from a shared link) renders the full post page, not the dialog.

Start fetching the post's data and full-size image on pointerdown or after 100 ms of hover, which hides most of the request time behind the user's tap.

Decision: how is each page rendered?

Two very different audiences hit the same app. A logged-in user opening the feed wants their personal ranked posts, and this HTML can't be shared between users or cached at the CDN. A visitor clicking a shared post link from a chat app, a search engine crawler, and a link preview bot all want the same public HTML for one post, and need it fast and complete without running JavaScript.

Decision How should pages be rendered?

Client rendering everywhere

Serve an empty app shell; JavaScript fetches and renders every page.

Pros

  • One simple deployment; the shell is cacheable everywhere.

Cons

  • Link preview bots see an empty page with no image or title.
  • Slow LCP for the visitor from a shared link: HTML, then JS, then API, then image.

Server rendering everywhere, no CDN cache

Every request, including the feed, renders HTML on the server with the user's data.

Pros

  • Fast first paint for every page.
  • Bots get full HTML.

Cons

  • The personal feed is rendered per request: high server cost, and slow server work blocks the first byte.
  • Public pages miss the CDN even though they're the same for everyone.

Split by audience

Public post and profile pages: server rendered, the same HTML for every logged-out visitor, cached at the CDN for a short time (for example s-maxage=60, stale-while-revalidate=600).

Logged-in home feed: server renders the shell and streams the first page of the feed, marked Cache-Control: private; later pages load from the client.

Pros

  • Bots and visitors get complete, cacheable HTML.
  • The feed still paints the first post fast.
  • Personal HTML never lands in a shared cache.

Cons

  • Two rendering paths to keep consistent.
  • Care needed so logged-in users never receive a cached logged-out page, or the reverse.

What we pick: Split by audience. Public pages are content pages and should behave like a blog post: server rendered, cacheable, with full metadata. The feed is an app screen that benefits from streaming the first page. Route the logged-in check before the cache, for example by varying the cache key on whether a session cookie is present, never on the cookie's value.

Two rendering paths side by side. Left, public post and profile pages: a visitor or a link preview bot requests the post page; the CDN answers from cache with s-maxage 60 and stale-while-revalidate, and only on a miss asks the SSR server, which renders HTML with Open Graph tags. The same HTML serves everyone. Right, the logged-in feed: the user requests the home page; the SSR server streams the shell and the first feed page marked private; the client app hydrates and fetches pages 2 and later as JSON. This HTML never enters a shared cache.public post, profilevisitor or preview botCDNs-maxage=60, SWRSSR serverHTML with og: tagsGET /p/C7xmiss onlysame HTML for everyonelogged-in feedlogged-in userSSR servershell + page 1, privateclient apppages 2+, as JSONGET /stream, hydratenever in a shared cache
The left path is a content page and behaves like a blog post. The right path is an app screen with personal data, so it's marked Cache-Control: private and only its first page is rendered on the server.

When someone pastes a post link into a chat app, the app's server fetches the URL and reads Open Graph tags from the HTML head to build the preview card. The server-rendered post page includes them:

html
<title>maya on Instagram: "Sunrise over the lake"</title>
<link rel="canonical" href="https://www.instagram.com/p/C7xYz12AbCd/" />
<meta name="description" content="1,204 likes, 38 comments. Sunrise over the lake" />
<meta property="og:type" content="article" />
<meta property="og:title" content="maya on Instagram" />
<meta property="og:description" content="Sunrise over the lake" />
<meta property="og:image" content="https://cdn.example.com/p/C7xYz12AbCd/1080.jpg" />
<meta property="og:image:width" content="1080" />
<meta property="og:image:height" content="1350" />
<meta property="og:image:alt" content="A lake at sunrise with mist over the water" />
<meta name="twitter:card" content="summary_large_image" />
Head tags for a public post page.
  • Use a JPEG (or PNG) for og:image. Support for AVIF among preview bots is patchy and WebP isn't universal, while JPEG works everywhere.
  • Private accounts: render a minimal page with the account name and 'This account is private', no post images in the HTML or the tags, and noindex for their posts.
  • The canonical URL removes tracking parameters, so search engines don't index the same post under 50 URLs.
  • For logged-out visitors, server render the first 12 grid tiles. Instagram itself asks visitors to log in after a little browsing; whether to do that is a product decision, but the first view must be complete HTML.

Check your understanding · Exercise 8

Which statements about rendering public posts are true? Select all that apply.

Architecture: the pieces and their boundaries

With the hard decisions made, we can draw the whole client. The guiding idea: UI components don't talk to the network or to each other's internals. They read from stores and call actions. A few long-lived services (the API client, the media pipeline, the upload queue, the playback coordinator, the story clock) do the work that outlives any one component.

Architecture of the Instagram web client. On the main thread: Screens for feed, profile, and post, and the Stories viewer loaded on demand, both read from the Query cache, which holds pages and cursors, and the Entity store, which holds posts and users by id. The Query cache normalizes into the Entity store and fetches through the API client. Also on the main thread: the Story clock, the API client, the Video coordinator, and the Upload queue backed by IndexedDB. Outside the main thread: the Media worker sends processed blobs to the Upload queue, which sends chunks to Object storage. Requests from the API client pass through the Service worker to the API or BFF. Images load straight from the Image CDN.main threadScreensfeed, profile, postStories viewerloaded on demandQuery cachepages, cursorsEntity storeposts, users by idStory clockAPI clientVideo coordinatorUpload queueImage CDNAPI / BFFService workerMedia workerObject storagereadschunks
Components only read stores and call actions. Long-lived work lives in services: the upload queue outlives the composer, the coordinator sees every video, and image bytes never pass through our JavaScript.
PieceResponsibilityWhy the boundary is here
Route screens: Feed, Profile, Post, Activity, ComposerLayout and composition for each URL; read from stores; no fetch calls inside.Screens change often with product work. Keeping data logic out of them keeps them easy to change and test.
Post card and media boxRender one post: header, media with reserved aspect ratio, actions, caption, comment preview.Used in the feed, the post page, and the post dialog. One component, many surfaces.
Stories viewerFull-screen dialog, gestures, progress bars; reads from the story clock.Isolated so it can be loaded on demand (code split) when the user first taps the tray.
Server cache (query layer)Fetches pages by cursor, de-duplicates requests, refetches on focus, holds feed, profile, comments, notifications.A server-state library does this well; we don't hand-roll caching logic per screen.
Entity storeNormalized posts and users by id; the one place likes and counts live.So a like in the feed shows on the post page with no extra request.
Playback coordinatorWhich feed video plays; autoplay rules; sticky mute choice.A global concern: only one video at a time across all posts.
Story clockElapsed time, pause reasons, advance, preload next, batch seen marks.Pure logic with a fake clock in tests; the viewer just renders it.
Media workerDecode, rotate, crop, resize, filter, encode, in a Web Worker.Heavy CPU work off the main thread keeps the composer responsive.
Upload queuePer item state machine, chunking, retries, resume, idempotent create-post; persisted to IndexedDB.Outlives the composer screen and even the tab.
API clientAuth headers, error format, retries for safe requests, AbortController per request.One place for cross-cutting network rules.
Service workerCaches the app shell and recent media for offline reading; optional background sync.Runs outside the page; can answer requests when the network is down.

How data flows through it

Follow one feed screen from request to pixels, then a like back to the server. The first page arrives as server-rendered HTML with the JSON embedded for hydration, and the cache layer owns that page and its cursor. As the page is read, posts and users are normalized into the entity store. Post cards read from the store and request images from the image CDN directly with srcset, so image bytes never pass through our JavaScript.

A like goes the other way: the card calls an action, the store flips state, the action sends a request through the API client, and the reply confirms or rolls back the store.

Data flow for the home feed. Read path: the SSR server or BFF sends HTML with the first page of JSON to the Query cache, which normalizes posts into the Entity store; the Post card reads post 3141 from the store by id. Image bytes go straight from the Image CDN to the Post card's image element, chosen by srcset. Write path: a double tap on the Post card runs the Like action, which flips the Entity store at once and asks the API client to send PUT /posts/3141/like to the API; the reply confirms the like or rolls it back.SSR / BFFHTML + JSONQuery cachefeed pagesEntity storeposts by idImage CDNAVIF, 1080wPost cardpost 3141Like actionoptimisticAPIlikes serviceAPI clientretry, abortpage 1normalizepost by idbyteslikeflipsendPUT /posts/3141/likereply: confirm or roll back
Small JSON flows through our code; big media bytes skip it and go from the CDN to the image element. The write path changes the store first and lets the reply confirm or undo it.

Notice what crosses each arrow. JSON for posts is small and goes through our code, while media bytes are big and go from the CDN straight to image and video elements. Uploads go from the media worker to storage via signed URLs, and only ids and metadata come back through the API. Keeping the heavy bytes off the main JavaScript path is half of the performance story.

Think first

Why put likes in a normalized entity store instead of just updating the feed page in the query cache?

Check your understanding · Exercise 9

Which pieces should run outside React components, as long-lived services? Select all that apply.

Data model: what the client keeps, and where

The client holds a few kinds of data, and each lives where its lifetime fits. Server data that many screens share goes in the entity store, and lists and cursors go in the query cache. Things that must survive a reload go in IndexedDB. Things the user can share or bookmark go in the URL.

ts
type Media = {
  id: string; kind: "image" | "video";
  key: string;              // CDN path; sizes and formats are derived from it
  width: number; height: number; // after EXIF rotation
  color: string;            // dominant color placeholder, like "#8a7f6b"
  alt: string;              // author's alt text, or a generated one (flagged)
  altGenerated: boolean;
  durationMs?: number; posterKey?: string;
};
type Post = {
  id: string; authorId: string; caption: string; createdAt: string;
  media: Media[];           // 1 to 10, carousel order
  likeCount: number; commentCount: number;
  viewerHasLiked: boolean;
  previewCommentIds: string[];
  status: "published" | "pending" | "failed"; // pending = optimistic
};
type User = { id: string; username: string; avatarKey: string; isPrivate: boolean };
type StoryTrayItem = { userId: string; hasUnseen: boolean; latestAt: string };
type Story = { id: string; userId: string; media: Media; expiresAt: string; seen: boolean };
type FeedPage = { postIds: string[]; nextCursor: string | null };
type UploadItem = {
  localId: string; draftId: string; blob: Blob; width: number; height: number;
  sessionId?: string; bytesSent: number; mediaId?: string;
  state: "processing" | "queued" | "uploading" | "paused" | "failed" | "done";
  attempts: number;
};
Core client entities. Lists hold ids; entities hold the data.
Client data model, grouped by where it lives. In the entity store in memory: User with id, username, and avatar; Post with likeCount and viewerHasLiked, written by a user; Media with width, height, color, and alt, 1 to 10 per post; Comment with postId and text, pointing to its post. In the query cache: FeedPage with post ids and a cursor, pointing to posts, and the Story tray with user ids and unseen flags. In IndexedDB: UploadItem with the blob and session id, which becomes a Media item once uploaded, and the Draft with caption and idempotency key. In the URL: the open post or story.entity store, memoryUserid, usernamePostlikes, viewerHasLikedCommentpostId, textMediaw, h, color, alt1 to 10query cacheFeedPagepostIds, cursorStory trayuserIds, unseenIndexedDBUploadItemblob, sessionIdDraftcaption, keybecomes MediaURLroute/p/3141, /stories
Lists hold ids and entities hold data, so a like changes one Post and every list shows it. Anything that must survive a closed tab, like an upload in progress, lives in IndexedDB.
DataWhere it livesWhy
Posts, users, commentsNormalized entity store in memoryShared across feed, profile, and post page; one update fixes all.
Feed, profile, comments pages and cursorsQuery cache, keyed by list (feed, profile:maya, comments:post123)Refetch, garbage collection, and stale times per list.
Stories tray and story itemsQuery cache; items fetched per user on demandStories expire in 24 hours, so keep their stale time short.
Seen story ids not yet sentMemory, flushed in batchesLosing a few on a crash is acceptable; the server dedupes anyway.
Drafts and upload queueIndexedDBMust survive a closed tab. Blobs are stored directly; IndexedDB handles binary data.
Open post dialog, open story, profile tabURLShareable, back button works, survives reload.
Muted or unmuted choice, data saverMemory, plus localStorage for the data saver settingSmall and per device.
Recent feed JSON and media for offline readingService worker cacheLets the app open with the last feed on a dead network.

Two fields in the model are worth calling out. width and height are recorded after rotation, because they drive the reserved box in the feed; get them wrong and every portrait photo reserves a landscape box. And status on a post allows the pending, optimistic post to live in the same lists as real ones, so the feed and grid render it with the same components.

Check your understanding · Exercise 10

Which storage choices are sound? Select all that apply.

Interface: the API the client needs

We'll use a REST-style JSON API behind a backend-for-frontend, the same shape the news feed and Twitter guides use, so the details here focus on what's new: media dimensions, stories, uploads, and idempotent writes. GraphQL would work just as well; the important parts are the shapes, not the transport.

Method and pathPurposeNotes
GET /feed?cursor=...&limit=10A page of the home feedReturns posts with users embedded once, plus nextCursor.
GET /stories/trayThe tray: accounts with active storiesNo story items, just ids, unseen flags, and avatars.
GET /stories/:userIdStory items for one accountFetched when the viewer reaches that account, or one account ahead.
POST /stories/seenBatch of seen story idsIdempotent set add; sent with keepalive on page hide.
POST /uploadsStart an upload session for one fileRequest: size, type, width, height. Response: sessionId, signed upload URL, chunk size.
PUT to the signed URL with Content-RangeSend a chunkOn resume, ask the session for its current offset first. (tus does the same with PATCH and an Upload-Offset header.)
POST /posts with Idempotency-KeyCreate a post from uploaded media idsReturns the full post. Repeating the key returns the same post.
PUT /posts/:id/like and DELETE /posts/:id/likeSet like stateIdempotent. Response has the fresh likeCount.
GET /posts/:id/comments?cursor=...A page of commentsReplies fetched per comment with their own cursor.
POST /posts/:id/commentsAdd a commentClient sends a clientId so the optimistic copy can be matched.
GET /users/:username/posts?cursor=...&limit=12Profile grid pageThumbnails only: id, thumbnail key, kind, item count.
GET /activity?cursor=... and GET /activity/unread-countNotifications and the badgeThe count is one number, cheap to poll.
json
{
  "posts": [{
    "id": "3141", "authorId": "u9", "caption": "Sunrise over the lake",
    "createdAt": "2026-10-19T05:12:00Z",
    "media": [{
      "id": "m1", "kind": "image", "key": "p/3141/m1",
      "width": 1080, "height": 1350, "color": "#8a7f6b",
      "alt": "A lake at sunrise with mist over the water", "altGenerated": false
    }],
    "likeCount": 1204, "commentCount": 38, "viewerHasLiked": false,
    "previewComments": [{ "id": "c7", "authorId": "u3", "text": "Wow" }]
  }],
  "users": { "u9": { "id": "u9", "username": "maya", "avatarKey": "a/u9" },
             "u3": { "id": "u3", "username": "leo", "avatarKey": "a/u3" } },
  "nextCursor": "eyJyIjoxMCwicyI6Ijk4NzEifQ"
}
GET /feed response (trimmed). Media carry their own dimensions and placeholder color.
http
POST /posts
Idempotency-Key: 6f1c2b0e-8a1d-4f4e-9d3a-2b7c1e5f9a10
Content-Type: application/json

{ "mediaIds": ["m81", "m82", "m83"],
  "caption": "Weekend in the hills",
  "alt": { "m81": "Two hikers on a ridge", "m82": "", "m83": "" },
  "shareLocation": false }

HTTP/1.1 201 Created
{ "post": { "id": "3199", "status": "published", "media": [ ... ] } }
Creating a post after its media finished uploading.

Errors use one shape everywhere: { "error": { "code": "MEDIA_TOO_LARGE", "message": "...", "retryable": false } }. The retryable flag lets the upload queue and the like coalescer decide between retrying and failing without parsing messages. A 429 includes Retry-After, which the client obeys instead of its own backoff.

Key component interfaces

ts
type StoriesViewerProps = {
  trayOrder: string[];           // user ids in tray order
  startUserId: string;
  onClose: (lastSeen: { userId: string; storyId: string }) => void;
  onSeen: (storyIds: string[]) => void;    // batched by the clock
  photoDurationMs?: number;      // default 5000
};

type MediaCarouselProps = {
  media: Media[];                // first item's ratio is used for all
  onDoubleTap: () => void;       // like only, never unlike
  onIndexChange?: (index: number) => void; // triggers prefetch of index + 1
  priority?: boolean;            // true for the first post (LCP)
};
Props for the two components most likely to be reused elsewhere.

Check your understanding · Exercise 11

Which API design choices help the client most? Select all that apply.

Performance and poor networks

Most of the performance work is already in the earlier chapters: reserved media boxes, responsive images, one video at a time, the worker pipeline. This chapter ties it to the Core Web Vitals targets and adds what happens when the network is slow or gone.

Core Web Vitals, one by one

MetricTarget (75th percentile)What threatens it hereWhat we do
LCPAt most 2.5 sThe first feed photo waits for JS, then the API, then the image.Stream server HTML with the first page; first image fetchpriority="high"; preconnect to the image CDN; AVIF and WebP at the right width.
CLSAt most 0.1Media without dimensions; the stories tray appearing late and pushing the feed down; pending posts inserted above the viewport.aspect-ratio boxes; a fixed-height tray placeholder; insert new posts above the viewport only with scroll anchoring (Safari only added it in version 27, in 2026, so older iPhones lack it), or show a 'New posts' pill instead.
INPAt most 200 msDecoding big images, a long hydration task, re-rendering the whole feed on a like.Worker for image work; split hydration; the like updates one entity, so only one card re-renders.
  • JavaScript budget: the feed route should ship well under 200 KB of compressed JavaScript. The stories viewer, the composer with its filters and WebGL code, and the comment sheet are separate chunks loaded on first use. Preload the viewer chunk when the tray becomes visible, since a tap is likely.
  • Image CDN: one origin for all media, connected early with preconnect, serving HTTP/2 or HTTP/3 so many small thumbnail requests share one connection. Long cache lifetimes with Cache-Control: public, max-age=31536000, immutable, because a media key never changes its content.
  • Rendering cost: give each post card content-visibility: auto with a contain-intrinsic-size from the known height, so off-screen cards skip layout and paint even before virtualization kicks in.
  • Decoded memory: release videos when they leave the window, and close any ImageBitmap the composer created as soon as it's encoded.

Slow networks: degrade, don't break

On a weak connection the app should still work, just with less. Read the network hints the browser gives (navigator.connection.effectiveType and saveData in Chromium), and also measure your own: if the last 3 feed images took over 2 seconds each, treat the network as slow regardless of what the API says. In slow mode: request the 640 wide image instead of 1080 on phones, show posters instead of autoplaying video, skip carousel and story prefetching beyond the very next item, and lower the feed page size to 5.

Offline

Full offline Instagram makes little sense: the content is other people's new photos. But a good offline experience is cheap and matters on the subway.

  • App shell cached by the service worker, so the app opens instantly with no network instead of a browser error page.
  • Last feed: keep the most recent feed page's JSON (stale-while-revalidate) and let the browser's HTTP cache and a small service worker media cache (say the last 50 images, under 20 MB) serve their images. Opening offline shows the last feed with a banner 'You're offline. Showing posts from 10:42.'
  • Writes offline: likes are queued with their desired state and sent on reconnect (and dropped if the post was deleted). New posts go into the upload queue and stay pending with 'Will post when you're back online'. Comments are queued the same way, visibly marked as not yet sent.
  • Honest UI: never show a spinner forever. After a request has been waiting 10 seconds, show a retry option; when offline, say so plainly.

Read the service worker caching and offline-first lessons for cache strategies and storage limits. The one Instagram-specific warning: media caches grow fast. Put a hard cap on the number of entries and evict the oldest, or one long session can use up the origin's storage quota: new writes then fail, including saving the upload queue, and under device storage pressure the browser may clear the origin's data entirely.

Knowing it works

Send real-user metrics: LCP, CLS, INP per route, plus product-specific timings: time from Share to published, upload failure rate by network type, story start delay (time from tap to first frame), and the rate of stories where the viewer had to wait for media. That last one tells you directly whether preloading is tuned right.

Check your understanding · Exercise 12

Field data shows CLS of 0.3 on the feed for logged-in users only. Which causes are likely? Select all that apply.

Accessibility for a visual-first app

An app made of photos is hard to use without sight, and an app full of motion is hard to use with vestibular disorders or attention difficulties. Both groups are large. Accessibility here isn't a checklist at the end; it touches the upload flow, the data model, and the playback rules.

Alt text from the start

Every photo needs a text alternative, and the only person who really knows what matters in a photo is the author. So the composer has an 'Alt text' field per photo, in the advanced settings for each item, with a hint of what to write ('Describe what's in the photo for people who can't see it'). It's stored per media item and returned in every API response that includes media.

Most authors won't fill it in. The fallback is a generated description from an image recognition model on the server. Instagram announced automatic alt text in November 2018: short descriptions that list what object recognition detected, phrased as a guess (along the lines of 'may contain: 2 people, outdoors').

Treat generated text honestly: store it separately (altGenerated: true), phrase it as a guess, never present it as the author's words, and let the author replace it. Models make mistakes, sometimes offensive ones, so generated descriptions should stay short and literal, without guessing things like identity or emotion.

  • The image's alt combines what's useful: 'Photo by maya. A lake at sunrise with mist over the water.' The caption is separate text on the page, so don't repeat it in alt.
  • Carousels: each slide has its own alt, and the carousel announces position: 'Photo 2 of 4'. The image carousel guide covers the carousel pattern itself.
  • Videos in the feed: muted autoplay means no audio problem, but videos with speech need captions; the upload backend can generate them, with the same honesty rules.
  • Profile tiles: the accessible name says what the tile is and holds the alt of the first item.

Motion: autoplay, stories, and the heart

Check prefers-reduced-motion: reduce in one place and pass it to the playback coordinator, the story clock, and the animation code. With it on: feed videos don't autoplay (poster plus play button); the double-tap heart appears without the bouncing scale animation; carousel slides change without sliding.

Stories are trickier, because auto-advancing is the whole format. A good middle ground: stories still advance, but without the cube transition between accounts, and with a visible pause button that keeps the story paused until pressed again, not only while holding. Also keep an app setting for autoplay, independent of the OS setting, because some people want reduced motion in the OS but autoplay here, or the reverse.

Keyboard and screen readers

  • The feed is a list of articles. Each post is an article labelled by its author, with a heading, so screen reader users can jump post to post. The feed pattern from WAI-ARIA (role="feed" with aria-busy while loading) fits an infinite list and lets readers move with Page Down.
  • Every gesture has a button. Double tap to like has the heart button. Swipe in stories has previous and next buttons. Drag to reorder carousel items in the composer has move-left and move-right buttons. Hold to pause has a pause button.
  • Dialogs (stories viewer, post dialog, comment sheet, composer) trap focus while open, close with Escape, and return focus to the element that opened them. The native dialog element with showModal() does most of this.
  • Announcements: a polite live region for 'Post shared', 'Couldn't post, retry available', and story changes. Don't announce like count changes or progress bar ticks.
  • Contrast: text over photos (story captions, the username in the viewer) needs a gradient or shadow behind it, since the photo can be any color.

Check your understanding · Exercise 13

Which accessibility choices are right for this app? Select all that apply.

Running it in the interview

You have about 45 minutes, and this problem has more depth than fits. The interviewer wants to see you choose what matters, make decisions with reasons, and go deep on two or three things. Here's a plan that works.

MinutesWhat you doWhat to draw or say
0 to 5Scope and requirementsList the surfaces in scope, the mobile web assumption, the numbers (LCP, CLS, upload of a 12 MB photo on a slow network). Name what's out: reels, DMs, ranking.
5 to 12ArchitectureThe design diagram: screens, entity store, query cache, API client, media worker, upload queue, playback coordinator, service worker. One sentence per box.
12 to 17Data model and APIPost and Media with width, height, color, alt. The feed endpoint with a cursor. The two-step upload: sessions then create post with an idempotency key.
17 to 37Two or three deep divesPick from: media boxes and image sizes, the upload pipeline, the story clock, optimistic likes. Ask the interviewer which they prefer; if they don't mind, do upload and feed media.
37 to 45Performance, offline, accessibility, wrap-upCWV targets and how each is met; offline reading and queued writes; alt text and reduced motion. Recap the decisions.

Draw the wireframe of the feed first, with the stories tray on top: it gives you and the interviewer shared names for everything. Then draw the architecture boxes beside it and keep both visible. When you go into a deep dive, point at the box you're expanding. If the interviewer interrupts with a question, answer it, then say where you were and continue; that shows you're steering the session, which is itself a senior signal.

Follow-up questions to expect

Think first

The user picks 10 photos, writes a caption, taps Share, and immediately closes the tab. What happens?

Think first

How would you make the stories viewer feel instant when the user taps an avatar?

Think first

A celebrity post gets 50,000 likes a minute. Should the client show the like count live?

Think first

How do you test the story clock and the upload queue?

Common mistakes

  • Redesigning the news feed in detail and running out of time before stories or uploads.
  • Uploading original photos and leaving all the work to the server, with no plan for a dropped connection.
  • Forgetting EXIF orientation, so stored dimensions are swapped for portrait photos.
  • Optimistic likes sent as toggles, which break under retries and reordering.
  • Autoplaying every video, with sound, ignoring visibility, battery, and reduced motion.
  • Client rendering the public post page, so shared links have no preview.
  • Treating generated alt text as if the author wrote it.

Check your understanding · Exercise 14

You have 20 minutes for deep dives and the interviewer says 'your choice'. Which plans are strong? Select all that apply.

Summary

  • Instagram is a media problem first: reserve every media box from server-sent dimensions, let srcset and sizes pick the file, and keep image bytes off the JavaScript path.
  • Only one feed video plays at a time, chosen by a single coordinator that respects visibility, user pauses, hidden tabs, data saver, and reduced motion.
  • The stories viewer runs on one elapsed-time clock with a set of pause reasons, and always has the next story and the next account's first story decoded.
  • Shrink photos in a Web Worker (decode with EXIF rotation, crop, resize, encode) and keep the server as the final authority, with a fallback for formats like HEIC.
  • Upload media as soon as it's picked, separately from creating the post; resume large files; create the post with an idempotency key and show it optimistically.
  • Likes are optimistic and coalesced to the latest intent, sent as idempotent set-state requests, stored once in a normalized entity.
  • Render public posts and profiles on the server with Open Graph tags and cache them briefly; keep personal feed HTML private.
  • Alt text is part of the data model: the author's text first, a clearly marked generated guess second.

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