- The client (iOS app, Android app, web app) is the first and last component of every request — and the one you control least: it runs on slow, flaky, battery-powered devices you do not own, in versions you cannot force-update.
- Instagram is a thick client: it caches aggressively (memory + disk image caches, a local database), renders optimistically (a like appears instantly and is rolled back if the server refuses), and keeps a persistent upload queue so posts survive lost signal and app restarts.
- It loads the feed with cursor pagination and infinite scroll, prefetches the next posts, stories and video segments, and picks the right image rendition and format (size, WebP/AVIF, progressive JPEG, tiny placeholders) for the screen and network.
- It is network-frugal: reused connections, batched requests, push notifications and a persistent real-time connection instead of polling.
- Because millions of phones run old versions, APIs must stay backward compatible, features ship behind server-side flags, and a forced upgrade is the last resort.
- Client telemetry (startup time, scroll jank, image load time, crashes) is how you know whether the whole system is actually fast for real users.
1. The problem: why does the client deserve its own lesson?
Most system design discussions start at the load balancer and stop at the database. But the user never sees your database — they see an app on a phone. Every millisecond you save in the backend can be thrown away by a client that opens six new connections, downloads a 4 MB image for a 360-pixel-wide thumbnail, or blocks the screen while waiting for a "like" to be confirmed.
Consider what Instagram's app must survive every day:
- Hostile networks. A user in a Mumbai local train flips between 4G, 3G and no signal every few minutes. Round-trip times jump from 40 ms to 800 ms; packets get lost; connections die silently.
- Limited devices. A budget Android phone may have 2–3 GB of RAM shared with the OS and other apps, a slow CPU, and a nearly full storage chip.
- Battery and data budgets. If Instagram drains the battery or eats a prepaid data plan, people uninstall it.
- Version skew. On any given day, hundreds of millions of devices run dozens of different app versions — some months old. The backend must serve all of them correctly.
- Perceived speed. Users judge speed by what they see: does the feed appear instantly on open? Does the heart turn red the moment I tap? Does the next Reel start without a spinner?
The client is therefore a genuine distributed system component: it has caches, queues, retries, consistency decisions and versioning problems, exactly like the backend. Designing it well is often the cheapest way to make the whole product feel fast.
2. Thin vs thick clients
A client is any program that sends requests to your servers on behalf of a user. Clients sit on a spectrum:
- A thin client does almost nothing locally. It sends input to the server and displays whatever the server returns. Classic examples: a server-rendered website with no JavaScript, a remote desktop session, or a dumb terminal. All logic and state live on the server.
- A thick (fat) client holds significant logic and state locally: it caches data, validates input, renders UI from raw data, works offline and synchronises with the server later. Native mobile apps, desktop email clients and modern single-page web apps are thick clients.
| Thin client | Thick client | |
|---|---|---|
| Where logic runs | Server | Mostly on the device; server is the source of truth |
| Offline capability | None | Read cached content, queue writes |
| Perceived latency | Every action waits for a round trip | Instant UI from cache and optimistic updates |
| Shipping changes | Deploy the server — everyone gets it instantly | Needs an app release; old versions linger for months |
| Device requirements | Low | Needs memory, storage and CPU |
| Consistency | Simple — one copy of state | Local state can be stale or conflict with the server |
| Security | Logic hidden on server | Code can be reverse-engineered; never trust the client |
Real products blend the two. Instagram's apps are thick (local caches, offline queue, optimistic UI, on-device rendering and video playback), but some screens use server-driven UI: the server sends a description of what to render ("a carousel with these three cards"), which lets the team change layouts without an app release. The guiding rule is: the client owns the experience; the server owns the truth. Anything that matters for correctness or security — whether a user may view a private profile, how many likes a post has, whether a comment is spam — is decided by the backend. The client may predict outcomes, but the server decides them.
3. Mobile constraints: designing for the real world
3.1 Flaky and slow networks
Two numbers describe a network: latency (how long a round trip takes, the RTT) and bandwidth (how many bytes per second flow once data is moving). On mobile, latency usually hurts more than bandwidth, because a typical API call needs several round trips (DNS lookup, TCP handshake, TLS handshake, the request itself — see Networking basics).
| Network | Typical RTT to a nearby server (assumption) | Typical usable bandwidth | Notes |
|---|---|---|---|
| Wi-Fi / fibre | 10–40 ms | 20–500 Mbps | Usually good, but congested public Wi-Fi can be awful |
| 5G | 15–40 ms | 50–1000 Mbps | Coverage still patchy; frequent fallbacks to 4G |
| 4G / LTE | 30–100 ms | 5–50 Mbps | The workhorse network for most users |
| 3G | 100–500 ms | 0.5–3 Mbps | Still common in rural areas and emerging markets |
| 2G / EDGE | 300–1000 ms | 50–200 kbps | A single feed image can take seconds |
Design consequences:
- Minimise round trips, not just bytes: reuse connections, batch requests, return everything a screen needs in one response.
- Assume every request can fail or hang: set timeouts, retry idempotent requests with exponential backoff (see Resilience patterns), and show cached content while waiting.
- Adapt to measured conditions: the app continuously estimates bandwidth (bytes received ÷ time) and latency, and uses that estimate to pick image sizes, video bitrates and how aggressively to prefetch.
3.2 Battery: the radio is the expensive part
The cellular radio is one of the most power-hungry components in a phone. Cellular radios have a power state machine: an idle low-power state, and a high-power "connected" state used while sending or receiving. Crucially, after a transfer finishes the radio stays in a high-power state for a "tail" period — often several seconds, in case more data arrives — before dropping back to idle.
power
^ transfer tail (radio stays hot, ~5-15 s)
| ┌────────┐────────────────────┐
| │########│....................│
| idle │########│....................│ idle
+───────┘ └────────────────────└──────────────────> time
^ wake-up costs latency (100 ms - 2 s) AND energy
That means ten tiny requests spaced 20 seconds apart can cost far more energy than one request carrying the same data, because each one pays the wake-up and tail cost. Battery-friendly clients therefore:
- Batch non-urgent traffic (analytics events, read receipts, prefetches) and send it together, ideally while the radio is already awake for a user-initiated request.
- Avoid polling; rely on push notifications (section 11).
- Defer heavy work (uploading large videos, syncing caches) to moments when the device is charging or on Wi-Fi, using OS schedulers like Android
WorkManagerand iOSBGTaskScheduler/ backgroundURLSession. - Limit CPU/GPU work: decoding huge images, running animations off-screen and re-rendering lists unnecessarily all burn battery.
3.3 Data cost
For many users, especially in emerging markets, mobile data is prepaid and expensive relative to income. Assume a user scrolls 100 feed images at ~200 KB each and watches 10 minutes of Reels at ~1 Mbps:
images: 100 × 200 KB = 20 MB
video : 10 min × 60 s × 1 Mbps = 600 Mbit = 75 MB
total ≈ 95 MB per session → on a 1 GB/day plan that is ~10% of the day's data
That is why apps offer a data saver mode (lower-resolution renditions, no video autoplay or preloading on cellular), use efficient formats (WebP/AVIF save 25–50% versus JPEG), and never download the same bytes twice (disk cache). Meta also ships lightweight apps such as Instagram Lite, publicly described as a much smaller Android app aimed at low-end devices and slow networks.
3.4 Memory and storage
Images are small on the wire but huge in memory. A compressed JPEG is decoded into an uncompressed bitmap before it can be drawn, typically 4 bytes per pixel (red, green, blue, alpha):
1080 × 1350 pixels × 4 bytes = 5,832,000 bytes ≈ 5.8 MB in RAM
the same image as a JPEG on the wire ≈ 150–250 KB → ~25–40× bigger once decoded
20 such images held in memory ≈ 117 MB
Operating systems kill apps that use too much memory (Android's low-memory killer, iOS jetsam). So clients downsample while decoding (decode a 1080px image at 540px for a thumbnail grid → ¼ the memory), keep a bounded LRU memory cache (e.g. ~⅛ to ¼ of the app's memory budget), and recycle views in long lists (RecyclerView on Android, UICollectionView cell reuse on iOS) so only on-screen rows hold bitmaps. Disk is also finite: caches need a size cap and eviction, and the app should respond when the OS reports low storage.
📶 Network
High, variable latency and loss. Minimise round trips, time out, retry, adapt quality.
🔋 Battery
Radio wake-ups and tails dominate. Batch, push instead of poll, defer heavy work.
💸 Data
Bytes cost money. Right-size renditions, modern formats, data saver, never re-download.
🧠 Memory
Decoded bitmaps are huge. Downsample, bounded LRU caches, recycle views.
4. Client-side caching
A cache is a fast copy of data kept closer to where it is used. The client has several cache layers, each slower but larger than the one before. The goal: when the user opens the app, scrolls back up, or revisits a profile, the content appears without a network request.
4.1 Memory image cache
Holds decoded, ready-to-draw bitmaps keyed by URL plus target size. A hit costs microseconds and avoids both disk I/O and the CPU cost of JPEG decoding. It is small (tens to a couple of hundred MB) and uses LRU (least-recently-used) eviction: when full, the bitmap that hasn't been shown for the longest time is dropped. When the OS signals memory pressure, the app trims this cache first.
4.2 Disk image cache
Holds the compressed bytes exactly as downloaded from the CDN, typically capped at a few hundred MB, also LRU. A hit avoids the network entirely — critical for scrolling back up the feed, reopening the app on a train, or viewing a profile grid you saw yesterday. Because Instagram media URLs are content-versioned and immutable (a changed image gets a new URL), the disk cache never needs to check freshness: if the URL is in the cache, the bytes are correct. The only complication is that signed CDN URLs carry expiry parameters, so the cache key should be the stable part of the URL (the media ID + rendition), not the full signed query string.
Meta's open-source Android image library Fresco was built around exactly these layers (an encoded-memory cache, a bitmap cache and a disk cache, plus careful memory management on older Android versions).
4.3 HTTP cache
Both mobile networking stacks (OkHttp, URLSession) and browsers implement the standard HTTP cache: responses are stored according to Cache-Control, ETag and Last-Modified headers. For API responses that can be revalidated, the client sends If-None-Match: "etag" and the server replies 304 Not Modified with no body if nothing changed — one round trip, almost no bytes. Personalised API responses are usually Cache-Control: private, meaning this device may cache them but shared caches (CDNs, proxies) must not.
4.4 Local database
For structured data — the last-seen feed page, your own profile, the DM inbox, drafts, the pending-upload queue — apps use an on-device database: SQLite directly or through wrappers such as Room (Android) and Core Data / GRDB (iOS). It survives app restarts, supports queries ("unread threads sorted by time"), and allows the UI to render from local data while the network catches up. This leads to the common "cache-then-network" (stale-while-revalidate) pattern:
- App opens → immediately render the feed stored in the local DB (maybe 2 hours old). The screen is never blank.
- In parallel, request fresh feed from the API.
- When fresh data arrives, merge it: insert new posts at the top (or show a "New posts" pill rather than jumping the scroll position), update counters, drop deleted posts.
- Write the fresh data back to the local DB for next time.
| Layer | Stores | Typical size | Survives restart? | Freshness strategy |
|---|---|---|---|---|
| Memory image cache | Decoded bitmaps | 50–200 MB | No | Immutable URLs → never stale |
| Disk image cache | Encoded JPEG/WebP/video segments | 100–500 MB | Yes | Immutable URLs → never stale; LRU eviction |
| HTTP cache | Raw HTTP responses | 10–50 MB | Yes | Cache-Control, ETag revalidation |
| Local DB | Feed, profiles, inbox, drafts, upload queue | 10–200 MB | Yes | Show stale, refresh in background, server wins |
5. Optimistic UI
Optimistic UI means updating the screen as if the server had already accepted the action, then reconciling when the real response arrives. It works because most actions (likes, follows, saves, comments) succeed >99% of the time. Waiting a full round trip — maybe 300 ms on 4G, 2 s on a bad connection — before turning the heart red would make the app feel sluggish.
User App (UI + local state) API → Like service
| tap ♥ | |
|-----------------> | 1. heart turns red, count 41 → 42 |
| (instant) | 2. store pending op {id: op-7f3, like} |
| |--- POST /posts/123/like (op-7f3) ---->|
| | | 3. write like (idempotent)
| |<------------------ 200 {likes: 57} ----|
| | 4. reconcile: count = 57 (server wins) |
| |
| ─── failure case ──────────────────────────────────────────|
| |--- POST /posts/123/like ------------> X timeout / 403
| | retry with backoff (same op-7f3) ... |
| | gives up → ROLLBACK: heart grey, 41, |
| | toast "Couldn't like. Tap to retry" |
5.1 Making optimistic updates safe
- Keep the previous state so you can roll back exactly (not "decrement by one", which breaks if other updates arrived meanwhile).
- Make the request idempotent. Retrying a like must not create two likes. Either the operation is naturally idempotent (
PUT /posts/123/likes/me— "the like exists"), or the client sends an idempotency key (op-7f3) the server deduplicates on. See API design. - Server wins on reconcile. The response carries the authoritative state (liked = true, like count = 57). The client overwrites its guess. Counts are only ever approximate anyway — see Likes & counters.
- Coalesce rapid toggles. A user who taps like/unlike/like in one second should produce one final request ("liked"), not three racing ones whose responses could arrive out of order. Debounce briefly, or attach a client sequence number so stale responses are ignored.
- Roll back visibly but gently for user-visible content (a comment that failed shows a "Not posted · Tap to retry" label instead of silently disappearing).
// simplified optimistic like
function onLikeTapped(post) {
const before = { liked: post.liked, count: post.likeCount };
const opId = uuid(); // idempotency key
store.update(post.id, { liked: !before.liked,
likeCount: before.count + (before.liked ? -1 : 1),
pendingOp: opId });
api.setLike(post.id, !before.liked, { idempotencyKey: opId })
.then(server => {
if (store.get(post.id).pendingOp !== opId) return; // a newer tap superseded us
store.update(post.id, { liked: server.liked, likeCount: server.likeCount, pendingOp: null });
})
.catch(err => {
if (isRetryable(err)) return outbox.enqueue(opId, ...); // keep optimistic state, retry later
store.update(post.id, { ...before, pendingOp: null }); // rollback
toast("Couldn't like this post");
});
}
6. Offline mode and the persistent upload queue
6.1 Reading offline
With the cache layers above, an offline app can still show the last-loaded feed, profiles you visited, your DM history and anything in the disk image cache. The UI should clearly but quietly indicate staleness ("No internet connection" banner) and disable actions that truly need the network (search, loading new comments).
6.2 Writing offline: the outbox pattern
Writes — posting a photo, sending a DM, liking, commenting — go into a persistent outbox: a table in the local DB where each row is one pending operation with an idempotency key, payload, attempt count and next-retry time. A background worker drains it whenever the network is available. Because the queue is persistent, it survives the app being killed, the phone rebooting, or the user switching to another app.
6.3 Retries done right
- Exponential backoff with jitter. Wait 1 s, 2 s, 4 s, 8 s… up to a cap (say 5 minutes), each multiplied by a random factor. Jitter prevents millions of phones that lost signal at the same moment (say, a cell tower outage) from all retrying in perfect sync and hammering the backend — a self-inflicted DDoS called a retry storm.
- Distinguish error types. Network errors, timeouts,
429 Too Many Requestsand5xxare retryable (respectRetry-After).400/403/413are not — retrying won't change the answer. - Listen for connectivity changes rather than polling: when the OS says the network is back, wake the queue immediately.
- Idempotency again: the server may have committed a post right before the connection dropped. Retrying with the same client-generated key returns the existing post instead of creating a duplicate.
- Ordering: some operations depend on others (you cannot comment on a post that hasn't been created yet). Keep per-object FIFO order in the queue, or record dependencies.
6.4 Uploading big media reliably
A 60-second Reel can be 30–100 MB. Restarting from zero after a dropped connection at 95% would be cruel on a slow network. The client therefore uses resumable, chunked uploads: the file is split into chunks (e.g. 1–8 MB); each chunk is uploaded and acknowledged; after a failure the client asks the server "what offset do you have?" and continues from there. Uploads go directly to storage using pre-signed URLs issued by the Upload Service, so the media bytes never pass through the API servers. The client also pre-processes before upload: it resizes the photo to the maximum size the server will keep (Instagram's well-known limit is around 1080px wide for feed photos), strips unnecessary metadata, and compresses — turning a 12-megapixel, 5 MB camera photo into a few hundred KB.
On iOS, background URLSession upload tasks are handed to the OS, which continues them even if the app is suspended; on Android, WorkManager jobs with network constraints play the same role.
7. Pagination and infinite scroll
A user's feed is effectively endless, so the client loads it in pages (e.g. 10–20 posts per request). There are two main ways to ask for "the next page".
7.1 Offset pagination
GET /feed?offset=40&limit=20 — "skip 40 items, give me the next 20". Simple, and it lets you jump to page 7. But it has two serious problems for a live feed:
- Shifting data. If 3 new posts are inserted at the top between your page-1 and page-2 requests, everything shifts down by 3: you see the last 3 posts of page 1 again (duplicates). If items are deleted, you silently skip some.
- Cost. In a database,
OFFSET 100000still has to walk past 100,000 rows before returning anything; deep pages get slower and slower.
Feed at time T1: [P50 P49 P48 P47 P46 | P45 P44 P43 P42 P41] page 1 = offset 0, limit 5
^ next page should start here
3 new posts arrive: P53 P52 P51
Feed at time T2: [P53 P52 P51 P50 P49 | P48 P47 P46 P45 P44] page 2 = offset 5, limit 5
^^^^^^^^^^^ P48, P47, P46 shown AGAIN (duplicates)
7.2 Cursor (keyset) pagination
GET /feed?max_id=P46&limit=20 or GET /feed?cursor=eyJ0cyI6... — "give me 20 items after this specific item". The cursor is an opaque token the server returns with each page (next_cursor), usually encoding the sort key of the last item (e.g. its time-sortable ID — see ID generation) and sometimes a ranking session. Because it anchors to an item rather than a position, inserts at the top don't cause duplicates, and the database can seek directly with an index (WHERE id < :cursor ORDER BY id DESC LIMIT 20) — equally fast on page 1 and page 10,000.
| Offset / limit | Cursor (keyset) | |
|---|---|---|
| Request | ?offset=40&limit=20 | ?cursor=abc123&limit=20 |
| Stable under inserts/deletes | ❌ duplicates / skipped items | ✅ anchored to an item |
| Deep-page performance | ❌ O(offset) scan | ✅ index seek, constant |
| Jump to arbitrary page | ✅ easy ("page 7") | ❌ only next/previous |
| Total count / page numbers | Natural | Usually not provided |
| Best for | Admin tables, search results with page numbers, small static lists | Feeds, comments, DMs, followers lists — anything live and long |
Instagram's APIs use cursor-style pagination (public API responses carry fields like next_max_id / end_cursor and a more_available flag). For a ranked feed the cursor is even more important: the server has computed an ordering for this session, and the cursor tells it where in that ranked list you are, so the next page doesn't re-rank and reshuffle what you've already seen. More detail in API design and News feed.
7.3 Infinite scroll mechanics
- Render the first page from cache, then from the network.
- Watch the scroll position. When the user is within a threshold of the end (e.g. 5 posts or 1.5 screen heights from the bottom), request the next page before they reach it, so they never see a spinner.
- Only one "load more" request in flight at a time; ignore further triggers until it returns. Deduplicate items by ID when appending (defence in depth).
- Use list virtualisation / view recycling: only the ~10 rows near the viewport exist as real views; scrolled-away rows release their bitmaps so memory stays flat after 500 posts.
- Stop when the server says
more_available: false; show an "You're all caught up" marker (Instagram added exactly such a marker in 2018).
8. Prefetching: hiding latency before the user asks
Prefetching means downloading things the user is likely to need next, while they are busy with the current thing. Done well, the next post's image is already in the disk cache when it scrolls into view, so it renders in milliseconds instead of hundreds.
- Feed: when page N arrives, start downloading images for the first few posts of that page and the next few off-screen posts in scroll direction. When you're near the bottom, fetch page N+1's JSON.
- Stories: the stories tray at the top is ranked; prefetch the first story item of the first few accounts in the tray (and the next item of the story you're watching). Tapping a ring then opens instantly. See Stories.
- Reels / video: preload the first segment(s) of the next one or two videos (section 10).
- Navigation hints: when the user's finger lands on a profile link (touch-down), start fetching the profile header before the tap completes (touch-up) — a 100–200 ms head start.
9. Choosing the right image: size, format and placeholders
After upload, the media processing workers generate several renditions of every photo — e.g. 150px, 320px, 640px, 750px, 1080px wide, in more than one format — and store them in object storage. The feed API returns a list of candidate URLs with their dimensions, and the client chooses.
9.1 Picking the size
needed_px = view_width_in_points × screen_scale (device pixel ratio)
iPhone, full-width post: 390 pt × 3 = 1170 px → pick 1080 (largest available)
budget Android: 360 dp × 2 = 720 px → pick 750 (smallest ≥ needed)
profile grid (3 columns): 130 pt × 3 = 390 px → pick 480 or 640
Explore thumbnail on 2G / data saver → cap at 320 regardless of screen
Rule of thumb: choose the smallest rendition that is at least as large as the displayed pixel size, then cap it based on network quality and data-saver settings. Downloading a 1080px image for a 130pt grid cell wastes roughly 7× the bytes and the decode memory.
9.2 Picking the format
| Format | Size vs baseline JPEG (rough) | Strengths | Weaknesses |
|---|---|---|---|
| Baseline JPEG | 100% | Universal support, fast decode | Loads top-to-bottom; larger files |
| Progressive JPEG | ~95–100% | Shows a blurry full image after ~10–20% of bytes, sharpening as more arrive | Slightly more CPU to decode; multiple render passes |
| WebP | ~70–75% | Good compression, transparency, broad Android/browser and modern iOS support | Older iOS needed extra libraries |
| AVIF | ~50–60% | Best compression for photos, HDR | Slow to encode; decode support only on newer OS versions |
| HEIC | ~50–60% | iPhone camera default | Patent-encumbered; limited support outside Apple |
The client advertises what it can decode — browsers send Accept: image/avif,image/webp,*/*; native apps send their supported formats in the API request or pick from the candidate list — and the server or CDN returns the best format the device supports (content negotiation). Remember the CDN's cache key must then include the format, or an AVIF-capable phone might receive a JPEG cached for an older one (or worse, vice versa). Facebook engineers have publicly written about switching their iOS app to progressive JPEG to show a useful image sooner on slow networks.
9.3 Placeholders: never show an empty box
Even with a fast CDN, a 4G image fetch takes 100–500 ms. Instead of a grey box, the feed JSON itself carries a tiny preview so something meaningful appears with the text:
- Dominant colour — a single hex colour per image (7 bytes). Cheap, surprisingly effective.
- BlurHash — a ~20–30 character string encoding a few low-frequency colour components; decoded on-device into a smooth blurred gradient.
- Tiny thumbnail — a ~40×40 JPEG of a few hundred bytes, scaled up and blurred. Facebook publicly described sending ~200-byte preview photos by stripping the JPEG header (which is identical across images) and re-attaching it on the device.
The sequence the user sees: placeholder (0 ms, inline in JSON) → progressive low-quality scan (first few KB) → full image, with a short cross-fade. The page also reserves the correct height from the known aspect ratio, so text doesn't jump around as images load (layout shift).
10. Video: preloading and adaptive playback
Reels, Stories videos and feed videos are delivered as adaptive bitrate (ABR) streams: the pipeline encodes each video at several bitrates and cuts it into short segments (a few seconds each) listed in a manifest (DASH or HLS). The player picks a bitrate per segment based on measured bandwidth and buffer level — dropping to 360p in a tunnel rather than stalling.
The key client technique is preloading the start of upcoming videos:
- While you watch Reel #1, the client downloads the manifest and the first segment (roughly the first 1–3 seconds) of Reels #2 and #3 at a modest bitrate. Assume ~300–500 KB each — cheap compared with full videos of many MB.
- When you swipe, playback starts instantly from the preloaded segment while the player fetches the rest, possibly at a higher bitrate.
- Decoders are expensive: keep at most one or two player instances warm, and release them when far off-screen.
- Autoplay in the feed is muted, starts only when the video is mostly visible, and is limited (or disabled) on cellular with data saver on.
The metrics that matter are time-to-first-frame and rebuffering ratio (time stalled ÷ time watched); both are reported by client telemetry (section 14) and directly affect watch time, which feeds the ranking models.
11. Talking to the server efficiently
11.1 Connection reuse
Opening a new HTTPS connection costs a DNS lookup (if not cached), a TCP handshake (1 RTT) and a TLS handshake (1 RTT with TLS 1.3, 2 with TLS 1.2) before the first request byte is sent. On a 150 ms 3G link that's 300–450 ms of pure overhead. So clients:
- Use a single shared HTTP client per app (one OkHttp instance, one
URLSession) whose connection pool keeps connections alive and reuses them. - Prefer HTTP/2 or HTTP/3, which multiplex many concurrent requests over one connection, so 30 thumbnails don't need 30 connections. HTTP/3 (QUIC) additionally survives Wi-Fi ⇄ cellular switches without a new handshake — see Networking basics.
- Keep the number of distinct hostnames small (typically one API host and one CDN host family), because each hostname needs its own DNS lookup and connection.
- Pre-connect on app launch: resolve DNS and open the API and CDN connections while the splash screen shows, so the first real request is "warm".
11.2 Batching and shaping requests
- One request per screen. The home screen needs the feed, the stories tray, unread DM count and notification badge. Four sequential calls on 3G could take 2 seconds; one aggregated call (via a backend-for-frontend endpoint at the API gateway, or a GraphQL query) takes one round trip.
- Batch small writes. "Seen" events for stories, impression logs and read receipts are queued and sent together every few seconds or when the radio is already awake.
- Deduplicate in-flight requests. If two components ask for the same profile at the same time, issue one request and share the result.
- Compress payloads (gzip/brotli/zstd) and request only needed fields — a feed item on a thumbnail grid needs far fewer fields than in full view.
- Prioritise. User-blocking requests (the post you tapped) go first; prefetches and analytics run at low priority and yield bandwidth.
12. Getting updates: push vs polling
How does the app learn that someone liked your photo or sent you a message?
| Approach | How it works | Latency | Cost | Use for |
|---|---|---|---|---|
| Short polling | Ask "anything new?" every N seconds | Up to N seconds | Huge: most responses are empty; wakes the radio constantly | Rarely; simple dashboards |
| Long polling | Request is held open until there is news or a timeout (~30–60 s) | Near real-time | One held request per user; reconnect overhead | Fallback when sockets are blocked |
| Persistent connection (WebSocket / MQTT) | One long-lived bidirectional connection while the app is in the foreground | Real-time (tens of ms) | Server must hold millions of open connections | DMs, typing indicators, live comments, in-app badges |
| OS push (APNs / FCM) | Server sends to Apple/Google; the OS's single shared connection delivers to the device | Seconds (best-effort) | Cheap for the device — one connection for all apps | Notifications while the app is backgrounded or closed |
The numbers make polling's cost obvious. Assume 500M daily users polling every 30 s for even 4 hours a day:
requests per user = 4 h × 3600 s / 30 s = 480
total = 500M × 480 = 240 billion requests/day ≈ 2.8 million QPS
…and > 95% of them return "nothing new" — while waking every phone's radio 480 times.
Instagram's pattern is therefore a hybrid: when the app is in the foreground, it keeps a persistent real-time connection (Meta has publicly described MQTT-based persistent connections for its messaging apps) for DMs, typing indicators and live updates — see Direct messaging. When the app is in the background, the Notification Service sends pushes through APNs (iOS) and FCM (Android). A push can carry visible text ("alex liked your photo") or be a silent data push that tells the app to sync a small piece of state in the background. On each app open, the client also does a quick delta-sync to catch anything a best-effort push missed.
13. Old app versions in the wild
A server deploy reaches 100% of users in minutes. A mobile release reaches users over weeks or months: app store review takes time, staged rollouts start at 1%, and many people disable auto-update or simply never open the store. At any moment the backend may be serving dozens of app versions, some a year old. This is called version skew, and it drives several rules:
13.1 Backward-compatible APIs
- Additive changes only. Add new fields and endpoints; never remove or rename a field an old client reads, and never change the meaning or type of an existing field (e.g.
like_countfrom a number to a string). - Tolerant reader. Clients must ignore unknown fields and handle unknown enum values gracefully (render "unsupported post type — update the app" rather than crash). Schema formats like Protocol Buffers and GraphQL help enforce this.
- Know who's calling. Every request carries the app version and platform (Instagram's User-Agent famously looks like
Instagram 3xx.0.0.x Android (…)). The server can then tailor responses — e.g. not send a new post type to versions that can't render it. - Deprecate deliberately. Track traffic per app version on dashboards; only remove an old API path when its traffic is negligible. Explicit API versions (
/v1/,/v2/) are covered in API design.
13.2 Feature flags and server-driven configuration
New features ship in the binary switched off, behind a flag fetched from the server at startup. This separates deploying code from releasing a feature:
- Turn a feature on for 1% → 10% → 100% of users, by country, app version or device class.
- Run A/B experiments (does a new feed layout increase time spent?).
- Kill switch: if the feature crashes or overloads a backend, turn it off remotely in seconds — no app release needed.
Meta has publicly described its internal gating/configuration system (often called Gatekeeper) and its config-management infrastructure used for exactly this.
13.3 Forced upgrade
Sometimes an old version must go: a security hole, a protocol the backend can no longer support, or an API that is too expensive to keep. The client checks a minimum supported version (from config at startup, or a special API error such as an "upgrade required" response). Below a soft minimum, show a dismissible "Please update" banner; below the hard minimum, block the app with an update screen. Forced upgrades are disruptive — users with no data or storage may churn — so they are a last resort, used rarely and announced with grace periods.
14. The web client: SPA, SSR and service workers
| Single-Page App (SPA) | Server-Side Rendering (SSR) | Hybrid (SSR + hydration) | |
|---|---|---|---|
| First load | Blank page until a large JS bundle downloads, parses and fetches data | Server returns ready HTML → fast first paint | Fast first paint, then JS "hydrates" to become interactive |
| Navigation after load | Very fast, app-like; only data is fetched | Full page load per click (classic) | App-like after hydration |
| SEO / link previews | Weak unless pre-rendered | Excellent | Excellent |
| Server cost | Low — static files on a CDN | Higher — render per request | Higher |
| Examples | React/Vue apps built with Vite | Classic Django/Rails templates | Next.js, Remix, React Server Components |
Instagram's web app (instagram.com) is built with React — the UI library Meta created — and public profile and post pages need to load fast and be indexable and previewable when shared, which favours server rendering of the initial HTML followed by client-side navigation. Instagram itself began life as a Django app, the classic SSR world.
14.1 Service workers and PWAs
A service worker is a JavaScript worker the browser runs separately from the page, which can intercept every network request the site makes. It lets a web app behave like a native one:
- Offline shell: cache the HTML/CSS/JS "app shell" so the site opens instantly and works without a network.
- Caching strategies per route: cache-first for versioned static assets and images, network-first (falling back to cache) for feed data, stale-while-revalidate for profile data.
- Background sync: queue a failed like or comment and replay it when connectivity returns — the web equivalent of the outbox.
- Web push: receive notifications even when the tab is closed.
Together with a web app manifest, this makes a Progressive Web App (PWA) that can be "installed" to the home screen — attractive for users on low-storage phones in emerging markets.
15. Client telemetry: measuring what users actually experience
Server dashboards can show 50 ms API latency while users in Jakarta wait 4 seconds for the feed — because of DNS, TLS, slow radios, image decoding and the main thread being busy. Only the client can measure the end-to-end experience. Typical client metrics:
- Cold start time — app launch to first meaningful content (e.g. first feed image displayed).
- Time to first byte / full load for API calls and images, broken down by network type, country and device class.
- Scroll performance — dropped frames / "jank" (a 60 Hz screen needs a frame every 16.7 ms).
- Video — time to first frame, rebuffering ratio, bitrate selected.
- Reliability — crash rate, ANRs ("app not responding" on Android), request error rates, upload success rate.
- Product events — impressions (which posts were on screen and for how long), likes, shares — the raw material for ranking.
Events are buffered in a local file, batched and compressed, and uploaded periodically (or when the radio is already active) to a logging endpoint. High-volume events are sampled (log 1% of scroll-performance traces), every event carries app version, OS and network type, and privacy rules strip or never collect sensitive data. On the backend, these logs flow through Kafka into the Analytics & ML pipeline: dwell time and impressions train the feed and Explore ranking models, while performance metrics drive alerting and dashboards (see Observability). Releases are watched closely: if crash rate for the new version spikes in the 1% rollout, the rollout is halted.
16. How the client touches the architecture diagram
In the main diagram the client box has four kinds of arrows leaving or entering it. Each uses a different path through the system, with different protocols and caching rules:
- DNS — the app resolves the API hostname and the CDN hostnames. DNS answers with the address of a nearby edge or region, and the OS caches the answer for its TTL.
- Load balancer → API Gateway → services — every personalised, dynamic call (load feed page, like, follow, search, request an upload URL) goes over a reused HTTPS connection to the load balancer, then the API gateway (which authenticates the token and rate-limits), then the right stateless service. Responses are JSON with URLs to media, never media bytes.
- CDN — the client fetches the image and video URLs from the CDN, which serves from edge cache or origin-pulls from object storage. For uploads, the client sends bytes directly to object storage using a pre-signed URL from the Upload Service.
- Push — the Notification Service (a Kafka consumer) sends through APNs/FCM; the phone's OS delivers it even when Instagram isn't running. In the foreground, a persistent real-time connection (via the LB to the DM/real-time gateway) carries live events instead.
16.1 A cold start, step by step
Note how much happens before any backend service does real work: cache read, DNS, TCP/TLS, and image decode are all client-side or edge costs. That's why client telemetry measures "launch to first feed image" rather than API latency alone.
17. How Instagram uses these ideas
- Native, thick apps on iOS and Android, plus a React web app; Meta also publicly ships Instagram Lite, a much smaller Android app for low-end phones and slow networks.
- Speculative upload: the photo starts uploading while you write the caption, so "Share" feels instant; uploads are resumable and survive app restarts via a persistent queue.
- Optimistic engagement: likes, saves, follows and comments update the UI immediately and reconcile with the server's authoritative state.
- Cache-then-network feed: the last feed renders from local storage on launch, then fresh ranked content loads with cursor pagination and a "New posts" pill.
- Right-sized media: the API returns multiple renditions per post; the client chooses by screen size and network, with tiny inline previews so nothing is ever blank.
- Aggressive but budgeted prefetching of feed images, the first items in the stories tray and the opening segments of upcoming Reels.
- Real-time via a persistent connection in the foreground and APNs/FCM in the background.
- Server-controlled behaviour: features gated by flags and experiments, with kill switches and a minimum supported version.
Rough numbers (assumptions, not official stats)
Assume 500M DAU, each opening the app 10×/day:
cold/warm starts = 5 billion/day ≈ 58,000 per second on average
if each start made 4 sequential API calls instead of 1 aggregated call:
extra requests = 3 × 58,000 ≈ 174,000 QPS of avoidable load
extra latency on 4G ≈ 3 × 80 ms = 240 ms per launch
a 90% disk-cache hit rate on scroll-back/re-opened images saves the CDN
roughly 10× the bytes for those views — and the user's data plan too.
18. Trade-offs & common pitfalls
✅ Benefits of a smart, thick client
- Instant-feeling UI (cache-then-network, optimistic updates)
- Works offline and on terrible networks; writes are never lost
- Massive backend and CDN savings (disk caches, batching, push not poll)
- Lower data and battery cost for users → better retention
- Rich media experiences (instant Reels, smooth scrolling)
⚠️ Costs
- Local state can be stale or briefly wrong (optimistic guesses, cached counts)
- Two or three codebases (iOS, Android, Web) to keep consistent
- Slow release cycle and long-lived old versions → API compatibility burden
- Harder debugging: bugs depend on device, OS, network and app version
- Prefetching and caching consume device storage and can waste data
Common pitfalls
- Retry storms: immediate, synchronised retries without backoff or jitter turn a brief backend blip into a sustained overload.
- Non-idempotent retries: re-sending "create post" without an idempotency key creates duplicate posts.
- Offset pagination on a live feed: duplicates and skipped posts as new content arrives.
- Downloading full-resolution images for thumbnails: wasted bytes, slow loads and out-of-memory crashes.
- Unbounded caches: the disk cache grows to gigabytes and the OS (or the user) deletes the app.
- Polling for notifications instead of push: battery drain and millions of empty requests per second.
- Breaking old clients: removing or changing a field that a 6-month-old app version still depends on causes crashes you can't fix with a server deploy.
- Trusting the client for permissions, limits or prices.
- Not wiping per-user caches on logout or account switch, leaking one user's DMs to another.
- Measuring only server latency and missing that users actually wait seconds.
19. Real-world technologies
| Tool / technology | What it does | When to use it |
|---|---|---|
| Fresco (Meta), Glide, Coil | Android image loading: memory/disk caches, downsampling, progressive JPEG | Any Android app showing lots of images |
| SDWebImage, Kingfisher, Nuke | iOS image loading and caching | Image-heavy iOS apps |
| OkHttp, Cronet, URLSession | HTTP clients with connection pooling, HTTP/2, HTTP cache; Cronet (Chromium's stack) adds QUIC/HTTP/3 | All network calls; one shared instance per app |
| SQLite, Room, Core Data, GRDB, Realm | On-device databases | Offline feed, inbox, drafts, outbox queue |
| WorkManager (Android), BGTaskScheduler & background URLSession (iOS) | OS-managed background work with network/charging constraints | Uploads, sync, cache cleanup |
| ExoPlayer / Media3, AVPlayer | Adaptive video playback (DASH/HLS), preloading, caching | Reels, Stories, feed video |
| APNs, FCM, Web Push | OS-level push delivery | Background notifications |
| MQTT, WebSocket | Persistent bidirectional connections | DMs, typing indicators, live updates |
| React, Next.js, Remix; Workbox | Web UI with SSR/hydration; service-worker caching toolkit | Web client and PWAs |
| Firebase Remote Config, LaunchDarkly, in-house (Meta Gatekeeper) | Feature flags, experiments, kill switches | Every mobile app with more than a handful of users |
| Crashlytics, Sentry, custom logging pipelines | Crash reporting, performance traces, event logging | Client telemetry |
| Litho, ComponentKit (Meta open source) | Declarative, efficient UI rendering for long scrolling lists | High-performance feeds |
20. Interview corner
The interviewer says "the feed feels slow in India". Where do you look first, and what client-side changes would you make?
Measure first with client telemetry, broken down by country, network type and device: launch-to-first-image, DNS/connect/TLS time, API time, image download and decode time. Typical fixes: render the cached feed immediately (cache-then-network); pre-connect to API and CDN hosts at launch; aggregate the home-screen calls into one request; use HTTP/2 or HTTP/3 (QUIC handles loss and network switches better); choose smaller renditions and WebP/AVIF on slow networks; send tiny inline placeholders; prefetch the next posts' images; make sure the CDN and DNS route Indian users to Indian PoPs. Also consider a lite app or data-saver defaults for low-end devices.
How would you implement the like button so it feels instant but stays correct?
Optimistic update: flip the heart and adjust the count immediately, remember the previous state and a pending-operation ID. Send an idempotent request (PUT /posts/{id}/like or POST with an idempotency key). On success, overwrite local state with the server's authoritative liked flag and count. On retryable failure, keep the optimistic state and retry with exponential backoff via the persistent outbox; on permanent failure (403: post deleted / blocked), roll back to the saved state and show a toast. Coalesce rapid like/unlike taps into the final state and ignore responses superseded by a newer operation.
A user posts a Reel while going into a subway. How do you guarantee it gets posted exactly once?
Persist the post (local file path, caption, a client-generated idempotency key) in an on-device outbox before showing "Posting…". Upload media in resumable chunks to a pre-signed URL so progress isn't lost; hand the transfer to the OS background service so it continues if the app is suspended. Retry with exponential backoff and jitter, triggered by connectivity changes. When the media is complete, call "create post" with the idempotency key; the server deduplicates, so a retry after a lost response returns the same post instead of creating a duplicate. That gives effectively-once semantics from at-least-once delivery.
Offset or cursor pagination for the feed — why?
Cursor. The feed changes constantly: with offsets, new posts at the top shift positions and cause duplicates (or deletions cause skipped items). Offsets are also O(n) to skip in the database. A cursor encodes the position after the last item seen (e.g. its sortable ID or a ranked-session position), is stable under inserts, maps to an index seek and lets the server keep a consistent ranked ordering across pages. The trade-off is no "jump to page 7", which a feed doesn't need.
How do you ship a breaking API change when 20% of users run app versions older than six months?
Avoid breaking it: make the change additive (new field or new endpoint), keep serving the old shape to old versions identified by their app-version header, and ship the new client behind a feature flag. Monitor traffic per version; once old-version traffic is negligible, deprecate. If the old path must die (security), raise the soft minimum version to nag users, then the hard minimum to block, with a grace period. Clients should already be tolerant readers that ignore unknown fields and degrade gracefully on unknown enum values.
Why doesn't the app just poll for new notifications every 10 seconds?
Cost and battery. With hundreds of millions of active devices, 10-second polling means tens of millions of QPS of mostly empty responses, and every poll wakes the cellular radio, which then stays in a high-power tail state for seconds — draining the battery. Instead use OS push (APNs/FCM: one shared, efficient connection for all apps) when backgrounded, and a single persistent connection (WebSocket/MQTT) for real-time events while foregrounded, plus a cheap delta-sync on app open to catch missed pushes.
How should the client choose which image rendition to download?
Compute the displayed size in physical pixels (view width × device pixel ratio), pick the smallest available rendition at least that large, then cap it by network quality and data-saver setting. Prefer the most efficient format the device can decode (AVIF → WebP → JPEG), use progressive JPEG or an inline BlurHash/tiny thumbnail so something appears immediately, and decode with downsampling to the view size to save memory. The API provides the candidate list (width, height, URL) so the client decides without extra round trips, and the CDN cache key includes size and format.
21. Check your understanding
A 1080 × 1350 photo is 200 KB as a JPEG. Roughly how much memory does it take once decoded as a 32-bit bitmap, and what's one way to reduce that for a 3-column profile grid?
1080 × 1350 × 4 bytes ≈ 5.8 MB — about 29× the compressed size. For a grid cell ~390 px wide, download a smaller rendition and/or decode with downsampling to ~390 × 488, which needs ≈ 0.76 MB — roughly 1/8 of the memory.
Between page 1 and page 2 of an offset-paginated feed (limit 10), 4 new posts are inserted at the top. What does the user see on page 2?
Everything shifted down by 4, so page 2 (offset 10) starts with the last 4 posts of the old page 1: 4 duplicates, followed by only 6 genuinely new-to-the-user posts. Cursor pagination avoids this because page 2 starts strictly after the last item ID the user saw.
Why is exponential backoff with jitter better than plain exponential backoff for a mobile upload queue?
When a common cause (cell tower outage, backend blip) makes millions of clients fail at the same moment, plain exponential backoff makes them all retry at exactly 1 s, 2 s, 4 s… later — synchronised spikes that can knock the recovering backend over again. Randomising each wait spreads retries evenly over time.
A user taps like, then immediately unlikes. The "like" request's response arrives after the "unlike" response. What goes wrong with a naive client, and how do you fix it?
The naive client applies the late "liked = true" response and shows the post as liked, even though the user's final intent (and possibly the server's final state) is "not liked". Fix: tag each operation with an ID/sequence number and ignore responses for superseded operations, or debounce rapid toggles so only the final state is sent.
Assume 200M devices poll every 60 s for 3 hours a day. What average QPS does that create, and what replaces it?
Each device: 3 × 3600 / 60 = 180 polls/day. Total: 200M × 180 = 36 billion/day ÷ 86,400 ≈ 417,000 QPS, almost all returning "nothing new". Replace with push (APNs/FCM) in the background and a persistent connection in the foreground.
Why can Instagram's disk image cache treat a cached image as valid forever without asking the server?
Media URLs are content-versioned: once a rendition is generated it never changes, and an edit produces a new URL. So "URL in cache" ⇒ "bytes are correct". The cache only needs LRU eviction for space, and should key on the stable media ID/rendition rather than the expiring signature parameters.
For which of these would you NOT use optimistic UI: (a) like, (b) follow, (c) deleting your account, (d) posting a comment, (e) switching your account to private?
(c) and (e). They are high-stakes, hard-to-undo or privacy-critical; the user must know they actually took effect, so show progress and wait for the server. Likes, follows and comments are cheap to roll back and almost always succeed (comments can show a "posting…" state and "tap to retry" on failure).
- The client is part of the distributed system: it has caches, queues, retries, consistency choices and versioning problems.
- Design for bad networks, small batteries, expensive data and limited memory: fewer round trips, batching, push instead of polling, right-sized media.
- Use layered caches (memory, disk, HTTP, local DB) and cache-then-network so screens are never blank.
- Optimistic UI + idempotent requests + a persistent outbox with backoff and jitter = instant and correct.
- Cursor pagination, budgeted prefetching, placeholders and video preloading hide latency.
- Old versions live for months: additive APIs, tolerant readers, feature flags, kill switches, forced upgrade only as a last resort.
- Measure the real experience with client telemetry, not just server latency.
Related lessons
- Networking basics — the round trips, TLS and HTTP/2/3 behaviour behind every client request
- DNS — how the app finds the nearest API region and CDN PoP
- CDN — where the client's images and video segments come from
- API design — pagination cursors, idempotency keys and versioning from the server side
- Media upload — pre-signed URLs and resumable uploads that the client's queue drives
- Media processing — how the renditions the client picks between are produced
- Notifications — the push path via APNs/FCM
- Direct messaging — persistent connections for real-time updates
- Resilience patterns — retries, backoff, jitter and idempotency in depth
- Analytics & ML — where client telemetry and impression logs end up