AlgoInsight

Free lesson · Mobile System Design Interviews

Social Feed App - Deep Dive: Feed Loading & Caching

Medium13 min readmobilemobile-system-designinterview-prep

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:

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:

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:

  1. Load posts 0-19 initially
  2. When user scrolls near the bottom (say, post 15 is visible), silently load posts 20-39
  3. As the list grows, keep loading ahead
  4. 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:

  1. Clear the memory cache
  2. Ignore the disk cache (or mark it as stale)
  3. Fetch from network
  4. 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:

  1. We load in small pages (20 posts at a time)
  2. We cache aggressively (memory → disk → network)
  3. We prefetch ahead of time
  4. We reuse Views/Composables
  5. 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?

Back to Mobile System Design Interviews