Blog

Android System Design: Design a Photo Sharing App

Blog
Android System Design: Design a Photo Sharing App
20 min
·
November 20, 2025
·
Android Architect
·
hard

Android System Design: Design a Photo Sharing App

A complete Android system design guide for designing a photo sharing app like Instagram. Covers Clean Architecture, image loading with Glide vs Coil, multi-layer caching, RecyclerView performance, photo upload pipeline with pre-signed URLs and WorkManager, feed pagination with Paging 3, offline support, media permissions across Android versions, and memory management — all in a real interview conversation format for senior Android engineers.

Android System Design: Design a Photo Sharing App

Designing a photo sharing app is one of the most commonly asked questions in senior Android system design interviews. Meta, Google, Snap, Pinterest, and Twitter all use variations of it. On the surface it sounds straightforward — show photos, let users post photos. But the Android-specific depth is where most candidates struggle.

How do you load and display hundreds of photos in a feed without the app getting killed for memory? What's the difference between Glide's memory cache and disk cache, and why does it matter for a grid view? How does a photo upload survive a process kill mid-upload? What happens when the user scrolls a RecyclerView faster than images can load from the network?

Those are the questions the interviewer is really asking. This guide walks through all of them — in the kind of back-and-forth you'd actually have in the room.


Step 1: Clarify the Scope

Interviewer: Design a photo sharing app for Android.

Candidate: A few questions before I start. Are we designing just the viewer experience — browsing a feed, viewing photos, opening someone's profile — or are we also designing the upload flow? Do we need offline support, as in can users browse cached content without internet? Does the feed need infinite scrolling or is it a finite list? And any specific scale to keep in mind — startup scale or something like Instagram with hundreds of millions of users?

Interviewer: Design the full experience — feed browsing, photo viewing, and uploading. Offline browsing of cached content is nice to have. Assume a scale where performance matters but we don't need to over-engineer database sharding. Focus on the Android client depth.

Candidate: Got it. I'll cover the feed, the photo detail view, the upload pipeline, and caching — all with the Android client as the primary lens. Let me walk through requirements first.


Requirements

Functional

  • Browse a scrollable feed of photos from followed accounts
  • View individual photos full-screen with metadata (likes, comments, author)
  • Upload a photo with caption from the device gallery or camera
  • View a user's profile grid
  • Like and comment on photos
  • Offline access to recently viewed photos and feed

Non-Functional

  • Smooth scrolling — the feed must not jank, even on mid-range devices
  • Fast image loading — photos should appear quickly, with graceful placeholders
  • Resilient upload — a photo upload must survive network drops and process kills
  • Memory safe — loading many full-res images cannot OOM the app
  • Storage conscious — disk cache must be bounded and managed

Back-of-the-Envelope Estimates

Interviewer: What numbers are you working with?

Candidate: On the client side specifically:

plaintext
Feed page size: 20 photos per page
Average photo size (compressed, CDN-served): 200–400 KB
Thumbnail size (grid view): 20–40 KB
User's device storage budget for cache: 100–250 MB (configurable)
Memory budget for image bitmaps: ~15% of available RAM
  → On a 4 GB RAM device: ~600 MB
  → On a 2 GB RAM device: ~300 MB
 
Upload:
  Raw camera photo: 3–8 MB
  After client-side compression: 500 KB–1.5 MB

Two things stand out. First, if you load 20 full-res feed photos into memory simultaneously as full Bitmap objects, each 400 KB compressed JPEG decodes to roughly 4–8 MB of bitmap data. Twenty of those = 80–160 MB in memory, before the rest of the app is accounted for. Memory management isn't optional — it's the core challenge of a photo app on Android.

Second, a 1 MB compressed upload on a mobile connection can easily take 30–60 seconds, and the user might background the app halfway through. The upload pipeline must survive that.


Client Architecture

Interviewer: Walk me through the overall architecture.

Candidate: Clean Architecture with MVVM at the presentation layer, same pattern we'd use across any complex Android app.

plaintext
UI Layer
  FeedFragment / PhotoDetailFragment  ←  ViewModel (StateFlow)
  Jetpack Compose (or View-based with ViewBinding)
 
Domain Layer
  Use Cases: GetFeed, GetUserProfile, LikePhoto, UploadPhoto
 
Data Layer
  PhotoRepository
    ├── Remote Source  → Retrofit + OkHttp
    └── Local Source   → Room (cached feed, photo metadata)
 
Upload Pipeline (cross-cutting)
  PhotoUploadManager
    └── WorkManager (CoroutineWorker for background upload)
 
Image Loading (cross-cutting)
  Glide or Coil (handles memory + disk caching)

Room is the single source of truth for the feed and photo metadata. The UI always observes Room via Flow. When new feed data arrives from the network, it gets written to Room, which triggers UI updates. That means offline browsing just works — the ViewModel reads from Room regardless of whether the network is available.


Media Permissions: What's Changed and Why It Matters

What the permission landscape looks like: Android has changed how apps access device photos significantly across versions, and interviewers at companies like Meta and Snap expect candidates to know this.

Before Android 13, reading photos from the device required READ_EXTERNAL_STORAGE. From Android 13 (API 33), that permission was replaced with granular media permissions. READ_MEDIA_IMAGES for photos, READ_MEDIA_VIDEO for videos.

From Android 14 (API 34), it got even more granular. Instead of requiring access to the entire photo library, apps can use the Photo Picker — a system-provided UI that lets users select specific photos to share with the app, without granting broad library access. No runtime permission needed if you go through the system Photo Picker.

Interviewer: Which approach should the photo upload flow use?

Candidate: The system Photo Picker from Android 13 onwards, where available. It's the privacy-respecting default that Google actively recommends. Users select the photos they want to share, and your app only gets access to those specific files — not the full gallery. No runtime permission dialog needed.

For the gallery browsing view inside the app — where we show a grid of all the user's photos to let them pick one — we do need READ_MEDIA_IMAGES on Android 13+ or READ_EXTERNAL_STORAGE on Android 12 and below. The app should check which version is running and request the appropriate permission.

Calling out these version differences in an interview shows you're keeping up with platform changes, which is exactly what senior engineers are expected to do.


Image Loading: Glide vs. Coil — And Why the Choice Matters

This is a core topic in any photo app system design answer. Most candidates name one and move on. The right answer explains what each does internally and why that drives the choice.

What an image loading library does: it takes a URL or URI, checks if the image is already in memory or on disk, fetches it from the network if not, decodes the JPEG/PNG/WebP into a Bitmap, applies transformations (crop, resize, blur), and sets it into an ImageView or Compose AsyncImage. All of this happens off the main thread. The library also manages cancellation — if a RecyclerView item is recycled before the image loads, the in-flight request is cancelled.

Interviewer: Which image loading library would you use — Glide or Coil?

Candidate: For a new Kotlin-first project using Jetpack Compose, I'd use Coil. It's built on Kotlin Coroutines and integrates naturally with Compose via the AsyncImage composable. It adds roughly 1,500 methods to the APK — significantly less than Glide — and uses OkHttp for HTTP under the hood, which most apps already have as a dependency.

For a project with an existing View-based UI or heavy RecyclerView usage with complex transformations, Glide is the battle-tested choice. It handles animated GIFs, video stills, and has the RecyclerViewPreloader integration out of the box. Glide is also faster from cache in benchmarks.

The important thing to say is: don't implement your own bitmap cache with LruCache from scratch. Both Glide and Coil handle the full caching stack. Rolling your own is reinventing the wheel and almost certainly worse.

The Caching Stack: Two Layers, Both Matter

Interviewer: Explain how image caching works in the library you chose.

Candidate: Both Glide and Coil operate a two-layer cache — memory first, then disk.

Memory cache holds decoded Bitmap objects. Glide's memory cache is an LRU cache sized to approximately 15% of the available application RAM. When you load a URL into an ImageView, the library first checks this in-memory LRU. If it's there, the bitmap is set immediately — no disk I/O, no decoding. This is what makes already-seen images feel instant when you scroll back to them.

Disk cache holds either the original downloaded bytes or the transformed/decoded result, depending on the DiskCacheStrategy. With DiskCacheStrategy.RESOURCE, Glide caches the final transformed output — the decoded, cropped, resized bitmap written to disk. When the image scrolls off-screen and gets evicted from memory, the next load goes to disk rather than the network. Disk reads are measured in milliseconds; network requests are measured in seconds.

One subtle thing worth naming: Glide holds "active references" to bitmaps that are currently on screen. These don't count against the LRU. If a bitmap is visible and another part of the app requests the same URL, Glide reuses the active reference without any cache lookup. This prevents duplicate bitmaps for the same URL.

Interviewer: How do you size the disk cache?

Candidate: Glide defaults to 250 MB. For a photo app, that's probably too small — users might browse 50–100 photos in a session. I'd bump it to 500 MB or let users configure it. More importantly, I'd use DiskCacheStrategy.DATA for full-res photos in the detail view (cache the raw bytes, decode fresh each time to the correct size) and DiskCacheStrategy.RESOURCE for thumbnails in the feed grid (cache the decoded thumbnail, exact size, instant reuse).


RecyclerView Performance: The Feed Grid

The feed grid is the hardest rendering problem in a photo app. You're loading images just faster than the user can scroll, managing bitmap memory across dozens of visible and recently-visible cells, and trying to maintain 60fps throughout.

What causes jank in a photo RecyclerView:

Decoding a bitmap on the main thread. Allocating large bitmaps without downsampling. Loading the wrong size image (downloading a 4K photo for a 200x200dp thumbnail). Not cancelling in-flight requests when a view holder is recycled.

Interviewer: How do you keep the feed grid smooth?

Candidate: A few things working together.

First, always load images at the target display size, not the original resolution. Glide.with(context).load(url).override(targetWidth, targetHeight).into(imageView) — or Coil's equivalent. If the thumbnail slot is 300x300px, load a 300x300px image. Never load a 2MB full-res photo to fill a thumbnail cell.

Second, use Glide's RecyclerViewPreloader. It loads images for items just ahead of where the user is currently scrolling — typically 2–4 items ahead. By the time those cells come on screen, their images are already in the memory cache. The user never sees a loading placeholder for recent scrolling direction.

Third, use setHasStableIds(true) on the adapter and override getItemId() with stable, content-based IDs. This tells DiffUtil and the RecyclerView exactly which items are the same across updates, preventing unnecessary redraws when only unrelated items in the feed change.

Fourth, never do work in onBindViewHolder beyond setting pre-computed values. No date formatting, no URL string construction, no null checks that cascade into logic. All of that happens upstream in the ViewModel's map {} operators before the data reaches the adapter.

Interviewer: What about placeholder and error states?

Candidate: Always set a placeholder. Users on slow connections will see a blank grid cell for seconds if you don't. Use a lightweight solid color or a shimmer effect as the placeholder — not a bitmap, as that defeats the point.

For errors (image fails to load), show a distinct error drawable. The fallback error() call in Glide/Coil handles this. Important: the error drawable should be the same size as the placeholder so the grid layout doesn't shift when the error state appears.


Feed Pagination with Paging 3

Interviewer: How does infinite scroll work in the feed?

Candidate: Paging 3 with a RemoteMediator. This is the same pattern we'd use in a chat app — Room as the local cache, the RemoteMediator fetches the next page from the network and writes it to Room, and Room's PagingSource delivers it to the UI.

For a photo feed, the setup is:

plaintext
FeedRemoteMediator
  ├── REFRESH load type → fetch latest page, replace Room cache
  ├── APPEND load type  → user scrolled to near the end, fetch next page
  └── PREPEND           → return EndOfPaginationReached (we don't page upward)
 
PagingConfig:
  pageSize = 20
  prefetchDistance = 6   (start loading next page when 6 items from the end)
  enablePlaceholders = true  (show placeholder cells for items we know are coming)

enablePlaceholders = true is specific to photo grids. Because we often know the total item count from the server, Paging 3 can pre-allocate cells for items that haven't loaded yet. The grid doesn't jump or reflow when new items arrive — placeholder cells are already in the layout, and they get replaced in-place with real content.

Interviewer: What's the pagination strategy — page numbers or cursors?

Candidate: Cursor-based. Page numbers break in a live feed — if 10 new photos are posted between loading page 1 and page 2, page 2 contains duplicates. A cursor anchors to the last seen photo_id, so GET /feed?after=photo_id_xyz&limit=20 always returns the next 20 photos after that point, regardless of new insertions above it.


Photo Upload Pipeline

This is the most interesting part of the Android design, and where most candidates give a weak answer.

Interviewer: How does photo upload work?

Candidate: The wrong approach is to upload directly from the ViewModel or a Coroutine tied to the screen lifecycle. If the user backgrounds the app or the screen rotates, the coroutine dies and the upload is lost. The user comes back to find nothing happened. That's a terrible experience.

The right approach is WorkManager. Here's the full flow:

plaintext
User selects photo and taps "Post"


1. Copy photo to app's private storage
   (protects against the source file being deleted or moved)


2. Write an UploadJob to Room with status = PENDING
   → UI shows "Uploading..." state immediately


3. Enqueue a WorkManager CoroutineWorker (UPLOAD_PHOTO)
   with constraint: NetworkType.CONNECTED


4. Worker runs (even if app is backgrounded or killed):
   a. GET /uploads/presigned-url from backend
      → backend returns a pre-signed S3 URL + media_id
   b. PUT photo bytes directly to S3 via the pre-signed URL
      → bypasses the app server entirely, no bandwidth cost on server
   c. POST /photos { media_id, caption } to create the post
   d. Update Room: UploadJob status = COMPLETE
   e. Update Room: insert new PhotoEntity into feed


5. UI observes Room → "Uploading..." clears, new photo appears in profile grid

Interviewer: What's a pre-signed URL and why use it?

Candidate: A pre-signed URL is a time-limited URL that grants temporary write access to a specific S3 location. The backend generates it with its AWS credentials and hands it to the client. The client then uploads directly to S3 using that URL — no credentials exposed, no files routing through the app server.

The benefit for the upload pipeline is significant. The app server is out of the data path for the actual file bytes. It only handles the small metadata POST at the end. This is how Instagram, Snapchat, and virtually every photo app at scale handles uploads.

Interviewer: What if the upload fails midway through?

Candidate: WorkManager's retry policy handles it. If the worker returns Result.retry(), WorkManager re-executes it with exponential backoff — default is 30 seconds, doubling up to 5 hours. The network constraint means it won't even attempt the retry while offline.

The Room UploadJob record with status = PENDING acts as the persistent outbox. If the process is killed mid-upload and WorkManager hasn't had a chance to retry yet, the job is still in the queue. WorkManager persists its queue to disk — it survives process kills and device reboots.

One detail worth naming: enqueue uploads as unique work using the photo's URI as the unique name. ExistingWorkPolicy.KEEP means if the same photo is somehow queued twice (say the user tapped Post twice before the UI updated), only one upload job runs.

Interviewer: How do you show upload progress in the notification?

Candidate: WorkManager supports setProgressAsync() from within the worker. The app observes the WorkInfo state and updates a NotificationCompat.Builder with a determinate progress bar. This works even when the app is backgrounded — the notification updates live. When the upload completes, update the notification to "Photo posted!" or dismiss it.


Photo Detail View

Interviewer: How do you handle the full-screen photo view?

Candidate: A few things specific to the detail view.

Load at full resolution here — not the thumbnail. Use a different Glide request signature for the detail view so it caches separately from the thumbnail. DiskCacheStrategy.DATA is right here — cache the raw bytes, decode to the exact screen size, avoid storing a 1080x1080 cached bitmap when the screen is 1440x2560.

For pinch-to-zoom, don't implement it from scratch. PhotoView is the standard library — it extends ImageView with matrix-based touch scaling and handles fling, double-tap, and overscroll gestures correctly. It's been maintained for over a decade and gets gesture handling right in edge cases that a custom implementation will miss.

For the transition from feed thumbnail to detail view, use a shared element transition on the ImageView. Tag the feed item's ImageView with ViewCompat.setTransitionName(imageView, photoId). When navigating to the detail fragment, pass the transition name and let the framework animate the image expanding from its thumbnail position. This is a small detail but it's noticed immediately when it's absent.

Interviewer: What if the full-res photo loads slowly while the user is waiting on the detail screen?

Candidate: Show the thumbnail immediately while the full-res loads. Glide's thumbnail() method accepts a separate request builder for the thumbnail. The thumbnail renders from the disk cache (likely already there from the feed), and the full-res image fades in over it when it arrives. The user never stares at a blank or placeholder state — the thumbnail bridges the gap.


Offline Support

Interviewer: How does offline browsing work?

Candidate: Two layers.

First, Room holds the feed metadata — photo URLs, captions, like counts, author names. The ViewModel reads from Room regardless of connectivity. If the network is unavailable, the last fetched feed is still visible.

Second, Glide's disk cache holds the actual image files. Photos the user has already seen are on disk. Room has the URLs; Glide resolves them from disk cache transparently. The user can scroll through recent feed content without any network at all.

When the network returns, the RemoteMediator's REFRESH load type fires and updates Room with new content. The UI updates reactively — no explicit "refresh" button required.

Interviewer: How do you signal to the user that they're offline?

Candidate: Observe ConnectivityManager.NetworkCallback in the ViewModel and expose an isOffline: StateFlow<Boolean>. The UI shows a non-intrusive banner — "You're offline. Showing cached content." — when offline. Dismiss it when connectivity returns. Don't block navigation or show a full error screen — cached content is still useful.


Memory Management

Interviewer: How do you prevent OOM crashes on low-end devices?

Candidate: A few things.

First, never load full-res bitmaps into a grid cell. Always downsample to display size. Glide handles this automatically via override(width, height) — if you specify dimensions, it downsamples during decode before the bitmap ever hits memory.

Second, respect ComponentCallbacks2.onTrimMemory(). When Android signals that the system is under memory pressure, call Glide.get(context).trimMemory(level). Glide evicts less-recently-used bitmaps from the memory LRU in response. The app proactively releases memory before the OS kills it.

Third, set android:largeHeap="false" in the manifest. Requesting a large heap makes the app harder to kill but also means the garbage collector runs less frequently on a bigger heap, causing longer GC pauses that show up as dropped frames. Work within the standard heap by being smart about bitmap sizes.

Fourth, use Bitmap.Config.RGB_565 for thumbnails that don't need transparency. RGB_565 uses 2 bytes per pixel vs. ARGB_8888's 4 bytes — half the memory for photos that don't have transparent pixels, which is most photos.

Interviewer: How do you handle the RecyclerView bitmap lifecycle specifically?

Candidate: Glide cancels in-flight requests automatically when Glide.with(fragment) is used — it's lifecycle-aware. When a view holder is recycled, the request is cancelled. But there's one thing to do explicitly: call Glide.with(context).clear(imageView) in onViewRecycled(). This releases the bitmap reference held on the ImageView and allows it to be returned to Glide's BitmapPool. Without this, recycled views hold bitmap references longer than necessary.


Common Interview Follow-ups

"What happens if the pre-signed URL expires before the upload completes?"

Pre-signed S3 URLs have a validity window — typically 15 minutes to 1 hour. For most photos, the upload completes well within that window. But on a very slow connection, a large upload could theoretically exceed it. The UploadWorker should check the URL's expiry before starting the PUT, and if it's within, say, 2 minutes of expiry, request a new pre-signed URL from the backend before proceeding. Store the expiry timestamp alongside the URL in the UploadJob Room record.

"How do you handle photo editing — filters, crops — before upload?"

Photo editing happens on-device before the upload begins. Run filter and crop operations on a Bitmap using Canvas, then write the result to the app's private cache directory as a JPEG. The upload pipeline then picks up this processed file. The original untouched file from the gallery is never modified — editing always works on a copy. Memory is the concern here: never hold the original and the edited bitmap in memory simultaneously on a low-RAM device. Process and release.

"How would you design a stories feature — full-screen vertical photos with a progress bar?"

Stories are a separate content type from the main feed. Different data model, different display screen. The vertical full-screen view uses ExoPlayer for video stories and a standard ImageView with Glide for photo stories. The progress bar is a ValueAnimator ticking at 60fps, advancing across the story duration (typically 5–10 seconds per item). Auto-advance triggers when the animator completes. Pausing on long-press is standard — pause the animator and hold on the current story item. Cache the next few story items ahead of time using Glide's preloading API so they're ready before the user reaches them.

"The feed grid shows 3 columns. How do you make images load faster when the user starts scrolling?"

RecyclerViewPreloader. Register it as a RecyclerView.OnScrollListener. Provide a ViewPreloadSizeProvider (it knows the exact size of each grid cell) and a ListPreloadModelProvider (it knows the URL for each upcoming position). With these two pieces, Glide calculates the exact pixel size to request and fires Glide requests for upcoming cells before they come on screen. Combined with a warm disk cache from prior sessions, the user rarely sees a loading indicator.


Quick Interview Checklist

  • ✅ Clarified scope — feed browsing, detail view, upload, offline
  • ✅ Clean Architecture + MVVM, Room as single source of truth
  • ✅ Media permission tiers across Android 12, 13, 14 — system Photo Picker for upload
  • ✅ Glide vs Coil — named the trade-off, recommended Coil for Compose, Glide for View-based
  • ✅ Two-layer cache explained — memory LRU (active resources + LRU eviction) + disk
  • DiskCacheStrategy.RESOURCE for thumbnails, DiskCacheStrategy.DATA for full-res
  • ✅ Cache size reasoning — 500 MB+ disk for a photo app, tune to display sizes
  • ✅ RecyclerView: load at display size (not full-res), RecyclerViewPreloader, stable IDs
  • ✅ No work in onBindViewHolder — pre-compute everything upstream
  • ✅ Placeholder and error states — same size to prevent grid layout shifts
  • ✅ Paging 3 with RemoteMediator — cursor-based, Room as cache, enablePlaceholders
  • ✅ Upload pipeline: WorkManager CoroutineWorker, not ViewModel coroutine
  • ✅ Pre-signed URL pattern — client uploads directly to S3, bypasses app server
  • ✅ Unique work enqueue — ExistingWorkPolicy.KEEP prevents duplicate uploads
  • ✅ Upload progress via setProgressAsync()NotificationCompat progress bar
  • ✅ Photo detail: full-res load, PhotoView for pinch-to-zoom, shared element transition
  • ✅ Thumbnail → full-res bridge via Glide's thumbnail() API
  • ✅ Offline: Room holds metadata, Glide disk cache holds images, NetworkCallback for UI
  • ✅ OOM prevention: downsample to display size, onTrimMemory, RGB_565 for thumbnails
  • Glide.clear() in onViewRecycled() — releases bitmap references to pool

Conclusion

A photo app is the most visually demanding type of app to get right on Android. The margin for error is small — OOM crashes, janky scrolling, failed uploads, and slow image loading are all immediately visible to the user in a way that bugs in business logic simply aren't.

What sets senior candidates apart in this interview isn't just knowing that Glide has a cache. It's explaining the difference between active resources, memory LRU, and disk cache, and knowing which DiskCacheStrategy to use for which use case. It's knowing that WorkManager is the right tool for uploads, not a coroutine, and being able to explain exactly why. It's knowing that pre-signed URLs exist, why they matter, and what to do when one expires.

The design pillars:

  1. Room as single source of truth — the UI observes Room, never the network directly
  2. Glide or Coil for the full caching stack — never roll your own bitmap cache
  3. Load at display size, always — the most impactful OOM prevention measure
  4. WorkManager for uploads — the only tool that survives process kills and reboots
  5. Pre-signed URL upload pattern — direct to S3, no app server in the data path
  6. Paging 3 with RemoteMediator — cursor-based, Room-cached, with placeholders
  7. onTrimMemory + Glide.clear() in onViewRecycled — proactive memory citizenship


Frequently Asked Questions

What is the difference between Glide and Coil for Android image loading?

Glide and Coil are both image loading libraries that handle memory caching, disk caching, and bitmap decoding — but they differ in API style, architecture, and best-fit use case.

GlideCoil
LanguageJava/KotlinKotlin-only
Coroutines supportWrapped (not native)Native — built on coroutines
Compose integrationGlideImage composable (separate dependency)First-class AsyncImage composable
Custom transformationsExtensive built-in libraryLightweight, fewer built-ins
MaturityVery mature, battle-tested at scaleNewer, rapidly adopted
APK size impactSlightly largerLighter

Which to choose:

  1. Coil — for Kotlin-first, Compose-heavy apps. Native coroutines integration means less boilerplate and natural lifecycle handling
  2. Glide — for View-based apps, or when you need complex transformations (watermarking, advanced crop shapes, custom decode formats)
  3. Either library handles the core requirement — memory LRU cache + disk LRU cache + bitmap pooling — equally well

How do you prevent OOM crashes when loading photos in a RecyclerView?

OOM crashes in photo feeds happen when full-resolution bitmaps are decoded into memory without downsampling. A single 12-megapixel photo decodes to ~48 MB as a raw ARGB_8888 bitmap — 20 of those in a grid would be ~960 MB.

Four strategies that work together:

  1. Load at display size, not source size — use override(width, height) in Glide or size(ViewSizeResolver) in Coil. The library decodes the JPEG at the exact pixel dimensions of the ImageView, not at full resolution. A 400 KB JPEG decoded at 300×300 uses ~360 KB of RAM, not 48 MB
  2. Use RGB_565 for thumbnailsBitmap.Config.RGB_565 uses 2 bytes per pixel vs ARGB_8888's 4 bytes. Half the memory for images without transparency, which covers most photos
  3. Respond to onTrimMemory() — call Glide.get(context).trimMemory(level) in Application.onTrimMemory(). Glide evicts least-recently-used bitmaps from its memory cache before the OS kills the app
  4. Clear in onViewRecycled() — call Glide.with(context).clear(imageView) when a ViewHolder is recycled. This returns the bitmap reference to Glide's BitmapPool immediately instead of waiting for GC

What is a pre-signed URL and why use it for photo uploads?

A pre-signed URL is a temporary, time-limited URL that grants direct write access to a specific cloud storage object (S3, GCS) without exposing credentials to the client.

How it works in a photo upload flow:

  1. App calls the backend: POST /uploads/request — "I want to upload a photo"
  2. Backend generates a pre-signed URL (valid for 15–60 minutes) and returns it to the app
  3. App uploads the photo file directly to S3 using an HTTP PUT to the pre-signed URL
  4. No photo bytes ever pass through the app server — the server is never in the data path
  5. App notifies the backend: POST /photos/confirm { s3Key } — "upload is done, create the post"

Why not upload through the app server?

  1. The app server would become a bandwidth and memory bottleneck at scale
  2. A 1 MB photo × 10,000 concurrent uploads = 10 GB/sec of server-side I/O — impractical
  3. Direct S3 upload is faster (fewer network hops) and cheaper (no server egress costs)

Why use WorkManager instead of a coroutine for photo uploads?

WorkManager guarantees background work completes even if the app is backgrounded, the process is killed, or the device reboots. A ViewModel-scoped coroutine does not survive any of those events.

The failure scenarios a coroutine can't handle:

  1. App backgrounded — Android may kill the process to reclaim RAM. The coroutine dies mid-upload, losing all progress
  2. User swipes the app away — process is killed immediately. Upload stops
  3. Device reboots — all in-memory state is lost. The user would never know the upload failed
  4. Network drops mid-upload — a coroutine would throw an exception. WorkManager retries with configurable backoff

How WorkManager handles these:

  1. The upload job is persisted to WorkManager's Room-backed database before it starts
  2. If the process is killed, WorkManager re-launches the job on next startup
  3. The NetworkType.CONNECTED constraint means WorkManager waits for network before starting
  4. setExpedited() keeps the job running even under battery saver on Android 12+
  5. ExistingWorkPolicy.KEEP prevents duplicate upload jobs if the user taps upload twice

Rule of thumb: if the work must complete regardless of what happens to the app, use WorkManager. If the work is only needed while the screen is showing, use a coroutine.


How does Glide's cache work — what is the difference between memory cache and disk cache?

Glide uses a two-layer cache: an in-memory LRU cache for instant reuse, and a disk LRU cache for persistence across app sessions.

Memory cache (in-memory LRU):

  1. Holds decoded Bitmap objects ready to be placed directly into an ImageView
  2. Two tiers: active resources (bitmaps currently displayed) and memory LRU (bitmaps recently released)
  3. Default size: ~15% of available RAM
  4. Cleared when the process dies — does not survive app restarts

Disk cache (disk LRU):

  1. Holds compressed image data on storage — persists across app restarts
  2. Default size: 250 MB (configurable)
  3. Two DiskCacheStrategy options matter most:
StrategyWhat is cachedBest for
RESOURCEDecoded, resized bitmapThumbnails at fixed display size — fast decode on next load
DATAOriginal compressed source bytesFull-res images where display size varies
ALLBothWhen you need both fast re-display and size flexibility
NONENothingHighly dynamic content (livestreams, frequently-changing avatars)

For a photo feed: use RESOURCE for grid thumbnails (always displayed at the same size) and DATA for the full-res detail view (may be displayed at different zoom levels).


How do Android media permissions differ across Android 12, 13, and 14?

Android photo access permissions changed significantly across three major versions, and a photo app must handle all three code paths.

Android versionPermission to read photosNotes
Android 12 (API 32) and belowREAD_EXTERNAL_STORAGEBroad storage access
Android 13 (API 33)READ_MEDIA_IMAGES + READ_MEDIA_VIDEOGranular media-type permissions
Android 14 (API 34)+READ_MEDIA_VISUAL_USER_SELECTEDPartial access — user picks specific photos

How to handle this correctly:

  1. Use the system Photo Picker (ActivityResultContracts.PickVisualMedia) for upload flows — it requires no permissions at all and works on all Android versions back to API 21 via backport
  2. For reading your own app's previously-uploaded photos: no permissions needed (internal storage)
  3. For accessing the full gallery programmatically: request READ_MEDIA_IMAGES on API 33+ and READ_EXTERNAL_STORAGE on API 32 and below
  4. Handle READ_MEDIA_VISUAL_USER_SELECTED gracefully on Android 14 — the user may grant access to only 3 of their 300 photos
  5. Never request WRITE_EXTERNAL_STORAGE — it's been a no-op since Android 10

Best practice for uploads: always use the system Photo Picker. It eliminates the permission complexity entirely and provides a consistent, trusted UI.


How does Paging 3 with RemoteMediator work for a photo feed?

Paging 3 with RemoteMediator loads pages of photos from the network, caches them in Room, and serves the UI from Room as the single source of truth — enabling instant display of cached content and seamless offline browsing.

How the data flows:

  1. The Pager reads from a Room PagingSource — the UI only ever observes Room
  2. When the user scrolls near the end of the loaded data, Paging 3 calls RemoteMediator.load(APPEND)
  3. The RemoteMediator fetches the next page from the network using a cursor (the ID of the last loaded photo)
  4. New photo metadata is written to Room inside a database transaction
  5. Room's PagingSource detects the new rows and emits the updated list to the UI

Key design decisions:

  1. Cursor-based pagination, not offset — offset pagination breaks when new posts are added at the top; a cursor anchors to the last seen item ID
  2. REFRESH clears and replaces Room data — ensures stale cached content is replaced on pull-to-refresh
  3. enablePlaceholders = true — Paging 3 shows grey placeholder cells for not-yet-loaded positions, preventing list jumps as new pages arrive
  4. InitializeAction.SKIP_INITIAL_REFRESH — if Room has fresh cached data, skip the network call on app open and show cached photos instantly

How do you make a RecyclerView photo grid load images faster?

RecyclerViewPreloader is the primary tool — it fires Glide requests for upcoming grid cells before they scroll into view, eliminating the blank-cell delay.

How to set it up:

  1. Create a ViewPreloadSizeProvider — it reads the exact pixel dimensions of each grid cell from the layout
  2. Create a ListPreloadModelProvider — it maps upcoming list positions to their image URLs
  3. Register a RecyclerViewPreloader (combining both) as a RecyclerView.OnScrollListener
  4. Glide calculates the precise image size to request and fires requests for the next N cells as the user scrolls

Additional techniques:

  1. Stable IDs — implement setHasStableIds(true) and return unique IDs per item. Prevents RecyclerView from rebinding identical cells unnecessarily
  2. No work in onBindViewHolder — format strings, compute dimensions, and build Glide request options in the ViewModel or a DiffUtil callback, not at bind time
  3. Consistent placeholder size — make placeholder and error drawables the same size as the loaded image to prevent grid layout shifts

Which companies ask the Android photo sharing app system design question?

Meta, Google, Snap, Pinterest, Airbnb, and Twitter (X) regularly ask this question in senior Android engineer interviews.

Why it is a popular interview question:

  1. Platform breadth — it covers media permissions, image loading, memory management, upload pipelines, and feed pagination in a single question
  2. Depth signals seniority — a junior answer describes showing images in a list; a senior answer explains DiskCacheStrategy, WorkManager retry semantics, and pre-signed URL expiry handling
  3. Directly maps to real products — every company listed above runs a photo or visual content product, so the answer has immediate practical relevance

What interviewers specifically listen for:

  1. Choosing the right DiskCacheStrategy for thumbnails vs full-res — and explaining why
  2. Using WorkManager for uploads — not a ViewModel coroutine — and explaining the failure scenarios
  3. Loading at display size, not source size — and connecting this to OOM prevention
  4. Handling the pre-signed URL pattern — not uploading through the app server
  5. Explaining onTrimMemory and Glide.clear() in onViewRecycled() without being prompted

Reading about image caching and upload pipelines is one thing. Explaining them under pressure while sketching the architecture and handling follow-ups is a different skill — and it takes practice. Mockingly.ai has Android-focused system design simulations for senior engineers preparing for interviews at Meta, Google, Snap, Pinterest, and beyond.

Prepare for these companies

This topic is commonly asked at these companies. Explore their full question lists:

Interview Guide for This Topic

See how top companies test this question in their system design interviews:

Practice "Design a Photo Sharing App (Android)" with AI

Reading is great, but practicing is how you actually get the offer. Get real-time AI feedback on your system design answers.

Related Posts

Turn This Knowledge Into Interview Confidence

Reading guides builds understanding. Practicing under interview conditions builds the muscle memory you need to deliver confidently when it counts.

Free tier available • AI-powered feedback • No credit card required

Practice system design with AI