Free lesson · Mobile System Design Interviews
Social Feed App - Deep Dive: Feed Loading & Caching
Step 4, Deep Dive #1: Making the Infinite Scroll Work
We've designed the architecture. Now let's zoom in on the hardest part: loading a smooth, infinite feed.
Imagine a user scrolling through the feed for an hour. They're seeing hundreds of posts. The app needs to:
- Never run out of posts to show
- Keep scrolling smooth (60 frames per second)
- Not use up all the phone's memory
- Not drain the battery downloading images
This is where system design gets practical and real.
Theory: Infinite Scroll and Virtual Lists (Recycler Views)
This theory section explains the most important optimization for mobile lists: virtual scrolling. Without it, your app will crash when rendering 1000 posts.
The Problem: Too Many Views
When you scroll through a social feed with 100 posts, you might think: "Create 100 PostCard Views on screen."
Wrong. If you do this:
- ✗ Your phone creates 100 View objects in memory (1 MB each = 100 MB of RAM)
- ✗ Each View is drawn to the screen every frame (60 FPS = expensive)
- ✗ Scrolling is janky, battery drains, app crashes
Right approach: Create only 5-10 Views. Reuse them as the user scrolls.
Virtual Scrolling: The Magic Trick
Virtual scrolling (also called recycling or windowing) works like this:
Your screen shows 5 posts at once (posts 15, 16, 17, 18, 19)
In memory, you have exactly 5 View objects:
┌─────────────┐
│ View #0 │ → Post #15
│ View #1 │ → Post #16
│ View #2 │ → Post #17
│ View #3 │ → Post #18
│ View #4 │ → Post #19
└─────────────┘
User scrolls down. Post 15 leaves the top of screen.
Now you need to show posts 16-20. Instead of creating a new View:
1. Take View #0 (which was showing post 15)
2. Unbind the old data
3. Bind new data (post 20)
4. Move View #0 to the bottom of the list
New state:
┌─────────────┐
│ View #1 │ → Post #16
│ View #2 │ → Post #17
│ View #3 │ → Post #18
│ View #4 │ → Post #19
│ View #0 │ → Post #20 (recycled!)
└─────────────┘
Same 5 Views, just showing different data. You could scroll through 10,000 posts with 5 Views!
Android: RecyclerView
RecyclerView is Android's implementation of virtual scrolling. Here's how it works:
// 1. Define what a single row looks like
class PostViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val titleView: TextView = itemView.findViewById(R.id.title)
private val contentView: TextView = itemView.findViewById(R.id.content)
private val likeButton: Button = itemView.findViewById(R.id.like_button)
fun bind(post: Post) {
// Update the View with new data
titleView.text = post.authorName
contentView.text = post.content
likeButton.text = "${post.likeCount} likes"
}
}
// 2. Tell RecyclerView how to create and bind Views
class PostAdapter : RecyclerView.Adapter<PostViewHolder>() {
private val posts = mutableListOf<Post>()
override fun getItemCount(): Int = posts.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostViewHolder {
// This is called only 5-10 times (once per View in the window)
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_post, parent, false)
return PostViewHolder(view)
}
override fun onBindViewHolder(holder: PostViewHolder, position: Int) {
// This is called every time a View is recycled
// Position 1000 uses the same ViewHolder as position 5, just with different data
holder.bind(posts[position])
}
fun addPosts(newPosts: List<Post>) {
posts.addAll(newPosts)
notifyItemRangeInserted(posts.size - newPosts.size, newPosts.size)
}
}
// 3. Use in Activity
class FeedActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val recyclerView: RecyclerView = findViewById(R.id.feed)
val adapter = PostAdapter()
recyclerView.adapter = adapter
recyclerView.layoutManager = LinearLayoutManager(this)
// Load posts
viewModel.feed.observe(this) { posts ->
adapter.addPosts(posts)
}
}
}
⚠️ Watch out: If you forget to recycle and create new Views for every post, your app will become progressively slower (janky scrolling, high memory usage, battery drain). This is the #1 performance bug in poorly-written mobile apps.
iOS: UITableView and UICollectionView
iOS has similar recycling:
class PostTableViewCell: UITableViewCell {
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var contentLabel: UILabel!
@IBOutlet weak var likeButton: UIButton!
func configure(with post: Post) {
titleLabel.text = post.authorName
contentLabel.text = post.content
likeButton.setTitle("\(post.likeCount) likes", for: .normal)
}
}
// In ViewController
class FeedViewController: UITableViewController {
var posts: [Post] = []
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return posts.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
// iOS automatically reuses cells with identifier "PostCell"
let cell = tableView.dequeueReusableCell(withIdentifier: "PostCell", for: indexPath)
as! PostTableViewCell
cell.configure(with: posts[indexPath.row])
return cell
}
}
Modern SwiftUI with List or LazyVStack handles recycling automatically.
Memory Impact of List Rendering
Let's do the math:
Without recycling (naive approach):
User scrolls through 1000 posts
× 5 Views per post (content, image, buttons, etc.)
× 1 MB per View (text, images in memory)
= 5,000 MB = 5 GB of RAM!
Phone only has 4 GB total.
App crashes.
With recycling:
Screen shows 5 posts
× 10 Views (5 visible + 5 for buffer ahead/behind)
× 1 MB per View
= 10 MB of RAM
User scrolls 1000 posts
= Still 10 MB of RAM (Views are reused)
Difference: 10 MB vs 5 GB!
Adding a Prefetch Listener
Modern apps prefetch content before the user reaches the bottom:
// Android
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()
val totalItems = layoutManager.itemCount
// When user is within 10 items of the end, load more
if (lastVisiblePosition >= totalItems - 10) {
viewModel.loadMore()
}
}
})
Summary: Why Virtual Lists Matter
| Metric | Without Recycling | With Recycling |
|---|---|---|
| Memory usage | ~1 MB per item | ~1 MB per 100 items |
| Scroll smoothness | Janky, 20 FPS | Smooth, 60 FPS |
| Battery drain | Extreme | Minimal |
| Max scrollable posts | ~100 | 1,000,000+ |
🎯 Interviewer's Perspective: If you don't mention recycling or virtual scrolling, interviewers know you haven't built real mobile apps. This is fundamental.
The Infinite Scroll Experience
"Infinite scroll" is a mirage. You're not loading a million posts at once. Here's the magic:
- Load posts 0-19 initially
- When user scrolls near the bottom (say, post 15 is visible), silently load posts 20-39
- As the list grows, keep loading ahead
- Never let the user see the "end"
The user never knows they're scrolling through 20-post chunks. It feels seamless.
Here's how the ViewModel detects when to load more:
class FeedViewModel @Inject constructor(
private val postsRepository: PostsRepository
) : ViewModel() {
private val _feedState = MutableStateFlow<FeedState>(FeedState.Loading)
val feedState = _feedState.asStateFlow()
private val _isLoadingMore = MutableStateFlow(false)
val isLoadingMore = _isLoadingMore.asStateFlow()
private var currentCursor: String? = null
private val allPosts = mutableListOf<Post>()
init {
loadFeed()
}
fun loadFeed() {
viewModelScope.launch {
_feedState.value = FeedState.Loading
try {
val feed = postsRepository.getFeed(cursor = null)
allPosts.addAll(feed.posts)
currentCursor = feed.nextCursor
_feedState.value = FeedState.Success(allPosts.toList())
} catch (e: Exception) {
_feedState.value = FeedState.Error(e.message ?: "Unknown error")
}
}
}
// Called by the UI when user scrolls near bottom
fun onScrollNearEnd() {
if (_isLoadingMore.value) return // Already loading
if (currentCursor == null) return // No more data
viewModelScope.launch {
_isLoadingMore.value = true
try {
val nextFeed = postsRepository.getFeed(cursor = currentCursor)
allPosts.addAll(nextFeed.posts) // Append to existing list
currentCursor = nextFeed.nextCursor
_feedState.value = FeedState.Success(allPosts.toList())
} catch (e: Exception) {
// Show error toast, but don't crash
println("Error loading more: ${e.message}")
} finally {
_isLoadingMore.value = false
}
}
}
}
Key insight: When loading more, we append to the list, not replace it. The user keeps seeing old posts while new ones load.
Detecting Scroll Position
In Compose (Kotlin), the UI detects when the user is near the bottom:
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val feedState by viewModel.feedState.collectAsState()
val lazyListState = rememberLazyListState()
// Detect when user scrolls near bottom
LaunchedEffect(lazyListState) {
snapshotFlow { lazyListState.layoutInfo }
.collect { layoutInfo ->
val totalItems = layoutInfo.totalItemsCount
val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
// If last item is within 5 items of the bottom, load more
if (lastVisibleIndex >= totalItems - 5) {
viewModel.onScrollNearEnd()
}
}
}
LazyColumn(state = lazyListState) {
when (feedState) {
is FeedState.Success -> {
val posts = (feedState as FeedState.Success).posts
items(posts) { post ->
PostCard(post)
}
// Show loading spinner at bottom while loading more
if (viewModel.isLoadingMore.collectAsState().value) {
item {
CircularProgressIndicator()
}
}
}
// ... other states
}
}
}
In iOS (SwiftUI):
struct FeedScreen: View {
@StateObject var viewModel: FeedViewModel
var body: some View {
List(viewModel.allPosts) { post in
PostCard(post)
.onAppear {
// Detect when this is the last item
if post == viewModel.allPosts.last {
viewModel.onScrollNearEnd()
}
}
}
}
}
💡 Tip: Trigger loading the next page when the user has 5-10 items left, not when they reach the absolute bottom. This gives the app time to fetch before they see a loading spinner.
Caching Strategy: The Three-Layer Cake
Remember the caching strategies from Module 3? Here's how we apply them to the feed:
Layer 1: Memory Cache (Fast, Limited)
Stores recent posts in RAM. Ultra-fast but limited to a few MB.
class InMemoryPostCache(maxSizeMB: Int = 10) {
// LRU = Least Recently Used. When full, old posts get kicked out
private val cache = LruCache<String, Post>(
maxSize = maxSizeMB * 1024 * 1024
) {
post -> // How many bytes does one post take?
post.content.length +
post.author.name.length +
post.imageUrls.sumOf { it.length }
}
fun put(post: Post) {
cache.put(post.id, post)
}
fun get(postId: String): Post? {
return cache.get(postId)
}
}
Layer 2: Disk Cache (Medium Speed, Lots of Space)
Stores posts in the local SQLite database. Slower than memory but survives app restarts.
@Entity(tableName = "posts")
data class PostEntity(
@PrimaryKey val id: String,
val authorId: String,
val authorName: String,
val authorPicUrl: String,
val content: String,
val imageUrls: String, // JSON array as string
val createdAt: String,
val likeCount: Int,
val commentCount: Int,
val isLikedByCurrentUser: Boolean,
val fetchedAt: Long = System.currentTimeMillis() // When we cached this
)
@Dao
interface PostDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPosts(posts: List<PostEntity>)
@Query("SELECT * FROM posts ORDER BY createdAt DESC LIMIT :limit")
suspend fun getRecentPosts(limit: Int = 100): List<PostEntity>
@Query("SELECT * FROM posts WHERE id = :postId")
suspend fun getPostById(postId: String): PostEntity?
@Query("DELETE FROM posts WHERE fetchedAt < :olderThan")
suspend fun deleteOldPosts(olderThan: Long)
}
Layer 3: Network (Slowest, Always Fresh)
The backend API. Only hit when cache misses.
The Flow: How They Work Together
When the ViewModel asks for the feed:
1. User opens app or scrolls to load more
↓
2. FeedViewModel.loadFeed(cursor) is called
↓
3. Repository.getFeed(cursor) is called
↓
4. Check memory cache:
- Is the full feed (20 posts) in RAM?
- If YES: return immediately (1-2ms)
- If NO: go to step 5
↓
5. Check disk cache (SQLite):
- Are posts in the database?
- Are they fresh (loaded less than 30 minutes ago)?
- If YES: return (20-50ms) AND go to step 7 (background refresh)
- If NO: go to step 6
↓
6. Fetch from network:
- Call GET /feed?cursor=abc
- Wait for response (100-500ms, depending on network)
↓
7. Save to both caches:
- Insert into SQLite database (disk cache)
- Put into memory cache
↓
8. Return to ViewModel
↓
9. ViewModel updates UI
The "Stale-While-Revalidate" Pattern
This is a powerful caching technique. Here's the idea:
User opens app → show cached feed immediately → fetch fresh data in background → update UI smoothly
Instead of waiting 200ms for the network, the user sees posts in 20ms, even if they're slightly stale.
class PostsRepository @Inject constructor(
private val localDataSource: FeedLocalDataSource,
private val remoteDataSource: FeedRemoteDataSource,
) {
suspend fun getFeed(cursor: String?): FeedResponse {
// Step 1: Check disk cache
val cachedFeed = localDataSource.getFeed(cursor)
if (cachedFeed != null && !isCacheExpired(cachedFeed)) {
// Step 2: Return cache immediately to user
val result = cachedFeed
// Step 3: But also fetch fresh data in background
viewModelScope.launch {
try {
val freshFeed = remoteDataSource.getFeed(cursor)
localDataSource.saveFeed(freshFeed) // Update cache
// Now the next time they view the feed, it's fresh
} catch (e: Exception) {
// Network error; no problem, we already showed the cached version
}
}
return result
}
// Fallback: cache is expired or empty, so fetch from network
return remoteDataSource.getFeed(cursor)
}
private fun isCacheExpired(feed: FeedResponse): Boolean {
val cacheAgeMs = System.currentTimeMillis() - feed.cachedAt
val thirtyMinutesMs = 30 * 60 * 1000
return cacheAgeMs > thirtyMinutesMs
}
}
💡 Tip: "Stale-while-revalidate" is the secret to snappy apps. Show old data first, update quietly. Users never notice the staleness if the update happens in seconds.
Pull-to-Refresh: Clearing the Cache
When a user swipes down to refresh, they're saying "give me the latest." This means we should:
- Clear the memory cache
- Ignore the disk cache (or mark it as stale)
- Fetch from network
- Save to cache again
class FeedViewModel @Inject constructor(
private val postsRepository: PostsRepository
) : ViewModel() {
fun onPullToRefresh() {
viewModelScope.launch {
try {
// cursor=null means "start over from the top"
val freshFeed = postsRepository.getFeed(cursor = null, forceRefresh = true)
allPosts.clear()
allPosts.addAll(freshFeed.posts)
currentCursor = freshFeed.nextCursor
_feedState.value = FeedState.Success(allPosts.toList())
} catch (e: Exception) {
_feedState.value = FeedState.Error(e.message ?: "Refresh failed")
}
}
}
}
In the UI (Compose):
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val pullRefreshState = rememberPullRefreshState(
refreshing = viewModel.isRefreshing.collectAsState().value,
onRefresh = { viewModel.onPullToRefresh() }
)
Box(modifier = Modifier.pullRefresh(pullRefreshState)) {
LazyColumn {
// ... posts
}
PullRefreshIndicator(
refreshing = pullRefreshState.refreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}
Handling Edge Cases
Real feeds aren't perfect. Here's how to handle common problems:
1. Empty Feed
New user with no posts? Show a helpful message, not a blank screen.
is FeedState.Success -> {
val posts = (feedState as FeedState.Success).posts
if (posts.isEmpty()) {
Text("No posts yet. Follow people to see their posts!")
} else {
LazyColumn {
items(posts) { post ->
PostCard(post)
}
}
}
}
2. Network Error Mid-Scroll
User is scrolling, app tries to load page 2, network fails. What do we do?
fun onScrollNearEnd() {
if (_isLoadingMore.value) return
if (currentCursor == null) return
viewModelScope.launch {
_isLoadingMore.value = true
try {
val nextFeed = postsRepository.getFeed(cursor = currentCursor)
allPosts.addAll(nextFeed.posts)
currentCursor = nextFeed.nextCursor
_feedState.value = FeedState.Success(allPosts.toList())
} catch (e: Exception) {
// Don't crash. Show a toast or small error.
// The user can keep reading old posts, and try scrolling again later.
_loadMoreError.value = "Failed to load more posts"
} finally {
_isLoadingMore.value = false
}
}
}
3. Duplicate Posts
If the API glitches and sends the same post twice, we should deduplicate:
fun onScrollNearEnd() {
viewModelScope.launch {
_isLoadingMore.value = true
try {
val nextFeed = postsRepository.getFeed(cursor = currentCursor)
// Only add posts we haven't seen before
val newPosts = nextFeed.posts.filter { newPost ->
allPosts.none { it.id == newPost.id }
}
allPosts.addAll(newPosts)
currentCursor = nextFeed.nextCursor
_feedState.value = FeedState.Success(allPosts.toList())
} catch (e: Exception) {
// error handling
} finally {
_isLoadingMore.value = false
}
}
}
Image Caching: The Special Challenge
Posts have images. Loading 100 images on a scroll is slow. Here's how we cache images:
Memory Cache for Recently Viewed Images
class ImageLoader @Inject constructor(
private val memoryCache: LruCache<String, Bitmap>
) {
suspend fun loadImage(url: String): Bitmap {
// Check memory first
memoryCache.get(url)?.let { return it }
// Load from disk or network (see next sections)
val bitmap = loadFromDiskOrNetwork(url)
// Cache in memory
memoryCache.put(url, bitmap)
return bitmap
}
}
Disk Cache for Downloaded Images
class ImageLoader @Inject constructor(
private val cacheDir: File // Usually: /app/cache/images/
) {
private fun getCacheFile(url: String): File {
val fileName = url.hashCode().toString() // Convert URL to filename
return File(cacheDir, fileName)
}
suspend fun loadImage(url: String): Bitmap {
val cacheFile = getCacheFile(url)
// Check if image is already on disk
if (cacheFile.exists()) {
return BitmapFactory.decodeFile(cacheFile.absolutePath)
}
// Download and save
val bitmap = downloadImage(url)
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, FileOutputStream(cacheFile))
return bitmap
}
}
Smart Image Loading During Scroll
Don't load every image at full resolution immediately. Preload low-resolution thumbnails first:
@Composable
fun PostImage(imageUrl: String, imageLoader: ImageLoader) {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(imageUrl) {
// Step 1: Load low-res thumbnail (fast)
val thumbnail = imageLoader.loadImageThumbnail(imageUrl)
bitmap = thumbnail
// Step 2: Load full resolution in background
bitmap = imageLoader.loadImage(imageUrl)
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
)
}
}
RecyclerView/LazyColumn Item Reuse
The key to smooth scrolling: reuse list items instead of creating new ones.
When a post scrolls off the top of the screen, the View/Composable is reused for the post coming from the bottom. This prevents the phone from creating hundreds of Views (which is slow).
In Compose, this is automatic:
LazyColumn {
items(posts) { post ->
PostCard(post) // Compose reuses this composable
}
}
In RecyclerView (older Android):
class PostAdapter : RecyclerView.Adapter<PostViewHolder>() {
override fun onBindViewHolder(holder: PostViewHolder, position: Int) {
val post = posts[position]
holder.bind(post) // Reuse the View, just update the data
}
}
⚠️ Watch out: If you create new Views inside the list instead of reusing, scrolling will stutter and battery will drain. Always use RecyclerView or LazyColumn properly.
Prefetching the Next Page
Smart apps don't wait for the user to scroll near the bottom. They prefetch:
fun onScrollNearEnd() {
if (currentCursor == null) return
// Prefetch the NEXT page before user sees the end
// This happens in the background with low priority
viewModelScope.launch(Dispatchers.IO) {
try {
postsRepository.prefetchFeed(currentCursor)
} catch (e: Exception) {
// Silent fail; if they never scroll that far, no problem
}
}
}
The prefetched data is saved to cache. When the user scrolls to the end, it's already there (from disk cache), so loading is instant.
Quick Decision Card: Caching Strategy
If an interviewer asks "How do you cache the feed?", here's the table:
| Decision | What We Chose | Why |
|---|---|---|
| Cache Layers | Memory → Disk → Network | Progressive fallback; fast to slow |
| Cache Duration | 30 minutes | Old enough to reduce server load, fresh enough for UX |
| Stale-While-Revalidate | Yes | Show cached data fast, update quietly |
| Pull-to-Refresh | Clears cache, forces fetch | Respects user's explicit "refresh" action |
| Image Caching | Memory + Disk | Memory for current scroll, disk survives app restart |
| Prefetching | Yes, low priority | Invisible to user, but makes scrolling feel instant |
Common Pitfall: Loading Too Much Data At Once
Many candidates say "just load 1,000 posts at once." This is wrong:
❌ Bad:
// Don't do this
val allPosts = api.getAllPosts() // Fetches a million posts
✓ Good:
// Do this
val feed = api.getFeed(limit = 20) // Fetch 20, then 20 more, then 20 more
Why? 1,000 posts is 10+ MB. That's too much data to download, too much to store in memory, and too much to display smoothly.
🔗 Building on Previous: The caching strategies from Module 3 (memory cache, disk cache, network cache) are the foundation of this entire deep dive. Each layer serves a specific purpose in making the feed fast and responsive.
Summary
The feed is fast because:
- We load in small pages (20 posts at a time)
- We cache aggressively (memory → disk → network)
- We prefetch ahead of time
- We reuse Views/Composables
- We handle errors gracefully
Next, we'll dive into a different challenge: what happens when the user is offline, or wants to like a post without internet?