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
| Question | Assumed answer | Why 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.
| Requirement | Target | What 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 4G | Server-rendered first page of the feed or profile, the first image preloaded, responsive image sizes. |
| Layout shift (Cumulative Layout Shift) | At most 0.1 | Every media box sized from its aspect ratio before the image loads. |
| Response to a tap (Interaction to Next Paint) | At most 200 ms | Small main-thread tasks; image decoding and resizing off the main thread where possible. |
| Data used per feed screen | Around 100 to 200 KB of images per post on a phone | Pick the image width that matches the screen and device pixel ratio, modern formats (AVIF, WebP). |
| Memory | Stay smooth after scrolling 300+ posts | Virtualize or recycle off-screen posts; release video elements. |
| Upload reliability | A 12 MB phone photo on a train must not be lost | Client-side resize, chunked resumable upload, a persistent upload queue. |
| Accessibility | WCAG 2.2 AA | Alt 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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?
In a text feed, a post's height depends on how its text wraps, which you only know after rendering it. In an Instagram feed, most of a post's height is the media box, and its height is known from the aspect ratio the server sends, before any pixels load. That makes height estimates much better, so the scroll position barely jumps when you swap estimates for measured sizes.
The harder parts are elsewhere: an off-screen post may hold a playing video that must be paused and released, and recycling a post must cancel its image download so it doesn't compete with what's on screen.
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?
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.
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.
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>
);
}
sizestells the browser how wide the image will be drawn: the full viewport on phones, a 470 pixel column on wider screens. Withsrcsetwidths, 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.
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?
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.
- 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.
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;
}
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.
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?
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.
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:
pointerdownstarts a 200 ms hold timer;pointerupbefore it fires is a tap (forward or back by which side), after it is a release from hold. - Set
touch-action: noneon 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?
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
- 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-imageis the default in today's spec, and since 2020 browsers also honor EXIF orientation by default when showing images (CSSimage-orientation: from-image). Older versions differed, so being explicit costs nothing. - 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.
- Resize. Draw the cropped area onto an
OffscreenCanvaswhose width is at most 1080 pixels. For big reductions, halving in two or three steps or settingimageSmoothingQuality = "high"avoids a jagged result. - Filter. Apply the chosen filter while drawing (next section).
- 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 checkblob.type. Re-encoding through a canvas also drops the EXIF block, which removes GPS data as a side effect. - 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.
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.
Multi-photo posts
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?
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.
online event without burning tries, while errors back off and give up after 5. Removing a photo from any state aborts its request.- Progress:
fetchhas no upload progress events, so useXMLHttpRequestand itsupload.onprogressfor 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.onLineturns false, pause the queue instead of burning retries, and resume on theonlineevent. TreatonLineas 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(orxhr.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.
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?
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.
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.
| Approach | When it fits | Cost |
|---|---|---|
| Poll the unread count every 60 s while the page is visible | The default. A one-number response, paused when the tab is hidden. | One tiny request a minute per active user. |
| Server-Sent Events or a WebSocket | If 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 Push | To 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, andaspect-ratioon 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
srcsetwith 320 and 640 widths andsizes="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?
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.
Cache-Control: private and only its first page is rendered on the server.Link previews and SEO
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:
<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" />
- 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
noindexfor 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.
| Piece | Responsibility | Why the boundary is here |
|---|---|---|
| Route screens: Feed, Profile, Post, Activity, Composer | Layout 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 box | Render 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 viewer | Full-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 store | Normalized 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 coordinator | Which feed video plays; autoplay rules; sticky mute choice. | A global concern: only one video at a time across all posts. |
| Story clock | Elapsed time, pause reasons, advance, preload next, batch seen marks. | Pure logic with a fake clock in tests; the viewer just renders it. |
| Media worker | Decode, rotate, crop, resize, filter, encode, in a Web Worker. | Heavy CPU work off the main thread keeps the composer responsive. |
| Upload queue | Per item state machine, chunking, retries, resume, idempotent create-post; persisted to IndexedDB. | Outlives the composer screen and even the tab. |
| API client | Auth headers, error format, retries for safe requests, AbortController per request. | One place for cross-cutting network rules. |
| Service worker | Caches 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.
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?
The same post appears in several cached lists: the home feed, the author's profile, a post page, and maybe a search result. If the like lives inside each cached list, every like has to find and patch every list, and the one you forget shows a stale heart.
With a normalized store, lists hold post ids and the post entity holds viewerHasLiked and likeCount. One update fixes every surface. The cost is a normalization step when pages arrive, which is cheap for 10 posts at a time.
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.
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;
};
| Data | Where it lives | Why |
|---|---|---|
| Posts, users, comments | Normalized entity store in memory | Shared across feed, profile, and post page; one update fixes all. |
| Feed, profile, comments pages and cursors | Query cache, keyed by list (feed, profile:maya, comments:post123) | Refetch, garbage collection, and stale times per list. |
| Stories tray and story items | Query cache; items fetched per user on demand | Stories expire in 24 hours, so keep their stale time short. |
| Seen story ids not yet sent | Memory, flushed in batches | Losing a few on a crash is acceptable; the server dedupes anyway. |
| Drafts and upload queue | IndexedDB | Must survive a closed tab. Blobs are stored directly; IndexedDB handles binary data. |
| Open post dialog, open story, profile tab | URL | Shareable, back button works, survives reload. |
| Muted or unmuted choice, data saver | Memory, plus localStorage for the data saver setting | Small and per device. |
| Recent feed JSON and media for offline reading | Service worker cache | Lets 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 path | Purpose | Notes |
|---|---|---|
GET /feed?cursor=...&limit=10 | A page of the home feed | Returns posts with users embedded once, plus nextCursor. |
GET /stories/tray | The tray: accounts with active stories | No story items, just ids, unseen flags, and avatars. |
GET /stories/:userId | Story items for one account | Fetched when the viewer reaches that account, or one account ahead. |
POST /stories/seen | Batch of seen story ids | Idempotent set add; sent with keepalive on page hide. |
POST /uploads | Start an upload session for one file | Request: size, type, width, height. Response: sessionId, signed upload URL, chunk size. |
PUT to the signed URL with Content-Range | Send a chunk | On resume, ask the session for its current offset first. (tus does the same with PATCH and an Upload-Offset header.) |
POST /posts with Idempotency-Key | Create a post from uploaded media ids | Returns the full post. Repeating the key returns the same post. |
PUT /posts/:id/like and DELETE /posts/:id/like | Set like state | Idempotent. Response has the fresh likeCount. |
GET /posts/:id/comments?cursor=... | A page of comments | Replies fetched per comment with their own cursor. |
POST /posts/:id/comments | Add a comment | Client sends a clientId so the optimistic copy can be matched. |
GET /users/:username/posts?cursor=...&limit=12 | Profile grid page | Thumbnails only: id, thumbnail key, kind, item count. |
GET /activity?cursor=... and GET /activity/unread-count | Notifications and the badge | The count is one number, cheap to poll. |
{
"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"
}
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": [ ... ] } }
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
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)
};
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
| Metric | Target (75th percentile) | What threatens it here | What we do |
|---|---|---|---|
| LCP | At most 2.5 s | The 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. |
| CLS | At most 0.1 | Media 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. |
| INP | At most 200 ms | Decoding 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 withCache-Control: public, max-age=31536000, immutable, because a media key never changes its content. - Rendering cost: give each post card
content-visibility: autowith acontain-intrinsic-sizefrom 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
ImageBitmapthe 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
articlelabelled by its author, with a heading, so screen reader users can jump post to post. The feed pattern from WAI-ARIA (role="feed"witharia-busywhile 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
dialogelement withshowModal()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.
| Minutes | What you do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope and requirements | List 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 12 | Architecture | The design diagram: screens, entity store, query cache, API client, media worker, upload queue, playback coordinator, service worker. One sentence per box. |
| 12 to 17 | Data model and API | Post 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 37 | Two or three deep dives | Pick 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 45 | Performance, offline, accessibility, wrap-up | CWV 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?
The draft (blobs, caption, alt texts, idempotency key) and each item's upload session are in IndexedDB. In Chromium, a background sync registration may finish the job from the service worker. Otherwise, the next time the user opens the app, the queue finds the pending draft, asks each session for its offset, resumes, and sends the create-post request with the same idempotency key.
The user sees the pending post at the top of their profile with 'Posting...', and a notification-style toast when it's published. If it's been days, ask before publishing an old draft.
Think first
How would you make the stories viewer feel instant when the user taps an avatar?
Three layers. Preload the viewer's code chunk when the tray first becomes visible. When the user's finger goes down on an avatar (pointerdown), start fetching that account's story list and its first image, which wins 100 to 200 ms before the tap completes. And, as a small exception to the 'no stories in the tray' rule, the tray response can carry just the first story's media key for the first few accounts, so the first image request needs no extra API call.
Then inside the viewer, always keep the next story and the next account's first story decoded.
Think first
A celebrity post gets 50,000 likes a minute. Should the client show the like count live?
No. Live counts would need a subscription per visible post and would change constantly, which is distracting and expensive. Show a compact count ('1.2M') from the last fetch, update it optimistically by one for the viewer's own like, and refresh when the post is refetched (on focus or on opening the post).
If the product insists on liveness, batch updates on the server and push at most one count update every few seconds for the posts on screen, over an existing connection.
Think first
How do you test the story clock and the upload queue?
Both are pure logic behind small interfaces, which is why we pulled them out of components. The clock takes a time source and media-ready events: drive it with a fake clock in unit tests and assert states ('hold during loading, release, still paused'). The upload queue takes a transport: fake it to fail on chunk 3, go offline, come back, and assert it resumes at the right offset and sends the idempotency key once per attempt.
End-to-end tests cover the happy paths in real browsers, plus one slow network profile for the upload.
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.
References
- MDN: Responsive images srcset, sizes, and picture with format fallbacks.
- web.dev: Browser-level image lazy loading How loading=lazy works, its distance thresholds, and why not to lazy load the LCP image.
- web.dev: Optimize Cumulative Layout Shift Reserving space for images and embeds.
- MDN: createImageBitmap() Decoding off the main thread, with the imageOrientation option.
- MDN: OffscreenCanvas Drawing and encoding images inside a worker.
- MDN: Autoplay guide for media and Web Audio APIs Muted autoplay, the play() promise, and how browsers block autoplay.
- Chrome for Developers: Autoplay policy in Chrome When Chrome allows autoplay with sound, and the Media Engagement Index.
- tus: resumable upload protocol An open protocol for chunked, resumable uploads.
- The Open Graph protocol The meta tags link preview bots read.
- WAI-ARIA APG: Feed pattern Accessible infinite lists of articles.
- WCAG 2.2: Pause, Stop, Hide Why auto-playing, moving content needs a pause control.
Concepts behind it
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Uploading filesHow a file gets from the user's device to your storage: through your server or straight to object storage with a pre-signed URL, in resumable chunks with honest progress, checked on both sides, and served back without opening a security hole.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Prefetching and preloading dataStarting the fetch for the data and code of the next screen before the user asks for it, on hover, on sight, or on a guess, so the click feels instant without wasting the user's data plan.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.