TL;DR

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:

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.

🧠 Analogy Think of a travelling journalist filing stories from remote villages. A thin journalist phones the newsroom for every fact and can do nothing when the line is down. A thick journalist carries a notebook of background facts (cache), writes the story offline, stamps it "pending" (optimistic UI), keeps a stack of sealed envelopes to post whenever a post office appears (upload queue), and reads ahead in tomorrow's schedule (prefetching). The newsroom (backend) still owns the truth — but the journalist stays productive even when the phone line crackles.

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:

Thin clientThick client
Where logic runsServerMostly on the device; server is the source of truth
Offline capabilityNoneRead cached content, queue writes
Perceived latencyEvery action waits for a round tripInstant UI from cache and optimistic updates
Shipping changesDeploy the server — everyone gets it instantlyNeeds an app release; old versions linger for months
Device requirementsLowNeeds memory, storage and CPU
ConsistencySimple — one copy of stateLocal state can be stale or conflict with the server
SecurityLogic hidden on serverCode 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.

⚠️ Never trust the client Every request can be forged with a script; the app's code can be decompiled. Authentication, authorisation, rate limits and validation must all happen server-side, at the API Gateway and in the services. Client-side validation is a UX nicety only. See Authentication and Rate limiting.

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).

NetworkTypical RTT to a nearby server (assumption)Typical usable bandwidthNotes
Wi-Fi / fibre10–40 ms20–500 MbpsUsually good, but congested public Wi-Fi can be awful
5G15–40 ms50–1000 MbpsCoverage still patchy; frequent fallbacks to 4G
4G / LTE30–100 ms5–50 MbpsThe workhorse network for most users
3G100–500 ms0.5–3 MbpsStill common in rural areas and emerging markets
2G / EDGE300–1000 ms50–200 kbpsA single feed image can take seconds

Design consequences:

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:

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.

📱 Feed screen needs data + images Memory cache decoded bitmaps (LRU) Disk image cache encoded JPEG/WebP CDN edge media bytes In-memory store parsed post models Local DB + HTTP cache SQLite: feed, drafts API LB → Gateway → Feed missmiss missmiss ~0.1 ms~5–20 ms50–500 ms ~0.1 ms~1–10 ms100–800 ms
Two cache hierarchies on the device: one for media bytes (top), one for API data (bottom). Each miss falls through to a slower, larger layer and finally the network.

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:

  1. App opens → immediately render the feed stored in the local DB (maybe 2 hours old). The screen is never blank.
  2. In parallel, request fresh feed from the API.
  3. 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.
  4. Write the fresh data back to the local DB for next time.
LayerStoresTypical sizeSurvives restart?Freshness strategy
Memory image cacheDecoded bitmaps50–200 MBNoImmutable URLs → never stale
Disk image cacheEncoded JPEG/WebP/video segments100–500 MBYesImmutable URLs → never stale; LRU eviction
HTTP cacheRaw HTTP responses10–50 MBYesCache-Control, ETag revalidation
Local DBFeed, profiles, inbox, drafts, upload queue10–200 MBYesShow stale, refresh in background, server wins
💡 What not to cache (or cache carefully) Auth tokens belong in the OS secure store (iOS Keychain, Android Keystore-backed storage), not in a plain cache. On logout or account switch, wipe per-user caches so the next user doesn't see the previous user's DMs. When a user blocks someone or a post is deleted, the server's response must be able to remove items from the local cache — design the sync protocol to carry deletions ("tombstones"), not only additions.

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

// 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");
     });
}
⚠️ When NOT to be optimistic Don't fake outcomes that are expensive to undo or that the user must be certain about: payments, deleting an account, changing a password, or making a private account public. For these, show a progress state and wait for the server.

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.

QUEUED UPLOADING CONFIRMING DONE ✔ WAIT (backoff) FAILED ✖ network okbytes sent201 Created retryable error timeout timer fires / network back max attempts, or 4xx (e.g. 403)
Lifecycle of one item in the persistent upload queue. Retryable errors loop through an exponential backoff wait; permanent errors end in FAILED with a "retry / discard" option for the user.

6.3 Retries done right

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.

📸 Instagram's classic "instant share" trick Early Instagram engineers publicly described a now-famous optimisation: the app starts uploading the photo as soon as you pick a filter — while you are still typing the caption and tagging friends. When you finally tap Share, only the small metadata request remains, so the post appears to publish instantly. If you abandon the post, the server discards the orphaned upload. It's optimistic, speculative work traded for perceived speed — a perfect example of client-side design beating backend optimisation.

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:

 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 / limitCursor (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 numbersNaturalUsually not provided
Best forAdmin tables, search results with page numbers, small static listsFeeds, 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

  1. Render the first page from cache, then from the network.
  2. 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.
  3. 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).
  4. 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.
  5. 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.

⚠️ Prefetching has a budget Every prefetched byte that is never viewed is wasted data, battery and CDN bandwidth. Good clients use a budget that adapts to context: aggressive on unmetered Wi-Fi with a full battery, conservative on cellular, minimal in data-saver or low-battery mode. They also prioritise (on-screen content first, prefetch at low priority) and cancel prefetches when the user scrolls quickly past or leaves the screen. A useful metric to track is prefetch hit rate = prefetched items actually displayed ÷ prefetched items.

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

FormatSize vs baseline JPEG (rough)StrengthsWeaknesses
Baseline JPEG100%Universal support, fast decodeLoads top-to-bottom; larger files
Progressive JPEG~95–100%Shows a blurry full image after ~10–20% of bytes, sharpening as more arriveSlightly more CPU to decode; multiple render passes
WebP~70–75%Good compression, transparency, broad Android/browser and modern iOS supportOlder iOS needed extra libraries
AVIF~50–60%Best compression for photos, HDRSlow to encode; decode support only on newer OS versions
HEIC~50–60%iPhone camera defaultPatent-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:

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:

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:

11.2 Batching and shaping requests

12. Getting updates: push vs polling

How does the app learn that someone liked your photo or sent you a message?

ApproachHow it worksLatencyCostUse for
Short pollingAsk "anything new?" every N secondsUp to N secondsHuge: most responses are empty; wakes the radio constantlyRarely; simple dashboards
Long pollingRequest is held open until there is news or a timeout (~30–60 s)Near real-timeOne held request per user; reconnect overheadFallback when sockets are blocked
Persistent connection (WebSocket / MQTT)One long-lived bidirectional connection while the app is in the foregroundReal-time (tens of ms)Server must hold millions of open connectionsDMs, 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 deviceSeconds (best-effort)Cheap for the device — one connection for all appsNotifications 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

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:

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 loadBlank page until a large JS bundle downloads, parses and fetches dataServer returns ready HTML → fast first paintFast first paint, then JS "hydrates" to become interactive
Navigation after loadVery fast, app-like; only data is fetchedFull page load per click (classic)App-like after hydration
SEO / link previewsWeak unless pre-renderedExcellentExcellent
Server costLow — static files on a CDNHigher — render per requestHigher
ExamplesReact/Vue apps built with ViteClassic Django/Rails templatesNext.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:

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:

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:

📱 Instagram app iOS · Android · Web ① DNS hostname → nearby IP ② Load balancer TLS, spreads load API Gateway auth, rate limits App services Feed, Upload, DM… ③ CDN edge photos, video segments Object storage CDN origin (S3) ④ APNs / FCM OS push channel Notification svc consumes Kafka lookupAPI (JSON)media GETpush missevents(Kafka)
The four client paths: ① name resolution, ② personalised API calls through LB → Gateway → services, ③ cacheable media straight from the CDN, ④ notifications arriving via Apple/Google push.
  1. 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.
  2. 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.
  3. 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.
  4. 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

Launchshow cached feed
→parallel
DNS + connectpre-warm API & CDN
→
GET homefeed + tray + badges
→URLs
CDNfirst images, story heads
→
Render & prefetchnext page, next reels

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

📸 Instagram's client, end to end

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.
📸 Why it matters for the backend Client decisions show up directly in backend load: polling vs push changes QPS by orders of magnitude; retry policy without jitter can turn a 2-minute outage into a 20-minute retry storm; and choosing image renditions badly can double CDN egress. When designing Instagram in an interview, mention the client explicitly — it's where many of the biggest wins live.

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

19. Real-world technologies

Tool / technologyWhat it doesWhen to use it
Fresco (Meta), Glide, CoilAndroid image loading: memory/disk caches, downsampling, progressive JPEGAny Android app showing lots of images
SDWebImage, Kingfisher, NukeiOS image loading and cachingImage-heavy iOS apps
OkHttp, Cronet, URLSessionHTTP clients with connection pooling, HTTP/2, HTTP cache; Cronet (Chromium's stack) adds QUIC/HTTP/3All network calls; one shared instance per app
SQLite, Room, Core Data, GRDB, RealmOn-device databasesOffline feed, inbox, drafts, outbox queue
WorkManager (Android), BGTaskScheduler & background URLSession (iOS)OS-managed background work with network/charging constraintsUploads, sync, cache cleanup
ExoPlayer / Media3, AVPlayerAdaptive video playback (DASH/HLS), preloading, cachingReels, Stories, feed video
APNs, FCM, Web PushOS-level push deliveryBackground notifications
MQTT, WebSocketPersistent bidirectional connectionsDMs, typing indicators, live updates
React, Next.js, Remix; WorkboxWeb UI with SSR/hydration; service-worker caching toolkitWeb client and PWAs
Firebase Remote Config, LaunchDarkly, in-house (Meta Gatekeeper)Feature flags, experiments, kill switchesEvery mobile app with more than a handful of users
Crashlytics, Sentry, custom logging pipelinesCrash reporting, performance traces, event loggingClient telemetry
Litho, ComponentKit (Meta open source)Declarative, efficient UI rendering for long scrolling listsHigh-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).

🎯 Key takeaways

Related lessons