AlgoInsight

Free lesson · Mobile System Design Interviews

Social Feed App - Deep Dive: Offline Support & User Interactions

Medium9 min readmobilemobile-system-designinterview-prep

Step 4, Deep Dive #2: The Magic of Offline & Optimistic Updates

We've covered loading the feed efficiently. Now for the part that makes social apps feel magical: working offline and updating instantly.

Imagine this: A user is on a subway with no signal. They scroll through the feed, see a post they love, and tap the like button. Instantly, the heart fills in red and the like count goes up. They feel the responsiveness.

Later, when the train emerges from the tunnel and signal returns, the like is quietly synced to the server.

That's what we're building in this section.

Part 1: Offline Reading

The easiest part: offline support for reading the feed.

The user previously loaded some posts. They're now saved in the SQLite database. Internet goes down. What happens?

class PostsRepository @Inject constructor(
    private val localDataSource: FeedLocalDataSource,
    private val remoteDataSource: FeedRemoteDataSource,
) {
    suspend fun getFeed(cursor: String?): FeedResponse {
        try {
            // Step 1: Try to fetch from network
            val freshFeed = remoteDataSource.getFeed(cursor)
            // Step 2: Save to local DB
            localDataSource.saveFeed(freshFeed)
            return freshFeed
        } catch (e: NetworkException) {
            // Step 3: Network failed. Check local database.
            val cachedFeed = localDataSource.getFeed(cursor)
            if (cachedFeed != null) {
                return cachedFeed  // Return what we have
            }
            // If no cache, rethrow the error
            throw e
        }
    }
}

The user sees the posts they previously loaded. It's not fresh, but it's better than nothing. The UI might show a small indicator: "Offline - showing cached posts."

@Composable
fun FeedScreen(viewModel: FeedViewModel) {
    val feedState by viewModel.feedState.collectAsState()
    val isOnline by viewModel.isOnline.collectAsState()

    Column {
        if (!isOnline) {
            Text(
                "You're offline. Showing cached posts.",
                color = Color.Gray,
                fontSize = 12.sp,
                modifier = Modifier
                    .fillMaxWidth()
                    .background(Color.LightGray)
                    .padding(8.dp)
            )
        }
        // ... rest of feed
    }
}

Part 2: The Offline Interaction Problem

Now it gets interesting. User is offline. They tap the like button on a post. What should happen?

Naive approach: Show an error: "You're offline, can't like posts."

❌ Bad UX. The user's intention is clear—they want to like the post. Why should the absence of internet stop them?

Better approach: Assume they'll go online later. Like the post locally, queue the action, and sync when online.

Optimistic Updates: The Key Pattern

Here's the pattern that makes offline apps feel instant:

  1. User taps "like" → Action happens immediately in UI (assume success)
  2. App queues the action → "Like this post when you're online"
  3. Network comes back → App syncs the queued action
  4. If server rejects → Revert the UI change and show error

Why "optimistic"? We're optimistically assuming the action will succeed, then fixing it if it doesn't.

Liking a Post: Optimistic Update

class PostsRepository @Inject constructor(
    private val localDataSource: FeedLocalDataSource,
    private val remoteDataSource: FeedRemoteDataSource,
    private val syncQueue: SyncQueue
) {
    suspend fun likePost(postId: String) {
        // Step 1: Update local database IMMEDIATELY
        // This makes the UI feel instant
        localDataSource.likePost(postId, isLiked = true)

        // Step 2: Try to sync to server
        try {
            remoteDataSource.likePost(postId)
            // Success! Nothing else to do.
        } catch (e: NetworkException) {
            // Network failed. But we already updated the local DB.
            // Queue this action to retry later.
            syncQueue.enqueue(
                SyncAction.LikePost(postId, isLiked = true)
            )
            // The user sees the like immediately (optimistic).
            // Next time they connect, it syncs.
        } catch (e: HttpException) {
            // Server rejected the like (e.g., post was deleted)
            // Revert the local change
            localDataSource.likePost(postId, isLiked = false)
            // Show error to user
            throw e
        }
    }

    suspend fun unlikePost(postId: String) {
        // Same logic, but for unliking
        localDataSource.likePost(postId, isLiked = false)

        try {
            remoteDataSource.unlikePost(postId)
        } catch (e: NetworkException) {
            syncQueue.enqueue(
                SyncAction.LikePost(postId, isLiked = false)
            )
        } catch (e: HttpException) {
            localDataSource.likePost(postId, isLiked = true)
            throw e
        }
    }
}

The Sync Queue: Remembering What to Do Later

The SyncQueue is a simple database table that stores pending actions:

@Entity(tableName = "sync_queue")
data class SyncActionEntity(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val actionType: String,  // "like_post", "comment", "create_post"
    val targetId: String,    // post ID or comment ID
    val payload: String,     // JSON data (e.g., {"liked": true})
    val createdAt: Long = System.currentTimeMillis(),
    val retryCount: Int = 0
)

@Dao
interface SyncQueueDao {
    @Insert
    suspend fun insert(action: SyncActionEntity)

    @Query("SELECT * FROM sync_queue ORDER BY createdAt ASC")
    suspend fun getAllPending(): List<SyncActionEntity>

    @Query("DELETE FROM sync_queue WHERE id = :actionId")
    suspend fun remove(actionId: Int)
}

class SyncQueue @Inject constructor(
    private val dao: SyncQueueDao
) {
    suspend fun enqueue(action: SyncAction) {
        dao.insert(action.toEntity())
    }

    suspend fun getPending(): List<SyncAction> {
        return dao.getAllPending().map { it.toDomain() }
    }

    suspend fun markSynced(actionId: Int) {
        dao.remove(actionId)
    }
}

Syncing When Network Returns

The app needs to detect when the network comes back and sync pending actions:

@Singleton
class SyncManager @Inject constructor(
    private val syncQueue: SyncQueue,
    private val postsRepository: PostsRepository,
    private val connectivity: ConnectivityManager
) {
    init {
        // Listen for network changes
        connectivity.registerDefaultNetworkCallback(
            object : ConnectivityManager.NetworkCallback() {
                override fun onAvailable(network: Network) {
                    syncPendingActions()
                }
            }
        )
    }

    fun syncPendingActions() {
        viewModelScope.launch {
            val pendingActions = syncQueue.getPending()

            for (action in pendingActions) {
                try {
                    when (action) {
                        is SyncAction.LikePost -> {
                            if (action.isLiked) {
                                postsRepository.likePostRemote(action.postId)
                            } else {
                                postsRepository.unlikePostRemote(action.postId)
                            }
                        }
                        is SyncAction.Comment -> {
                            postsRepository.commentRemote(action.postId, action.text)
                        }
                        // ... other actions
                    }
                    // Success! Remove from queue
                    syncQueue.markSynced(action.id)
                } catch (e: Exception) {
                    // Retry logic: if failed 5 times, stop trying
                    if (action.retryCount > 5) {
                        syncQueue.markSynced(action.id)
                        showError("Couldn't sync action. Giving up.")
                    }
                    // Otherwise, leave in queue to retry next time
                }
            }
        }
    }
}

💡 Tip: Always show optimistic updates, even on fast networks. It makes the app feel snappier than waiting for the server.

Part 3: Creating Posts Offline

What if the user tries to create a brand new post while offline?

Online version:

  1. User writes post + picks image
  2. App uploads image to cloud storage (presigned URL)
  3. App sends POST /posts with image URL
  4. Post appears in feed

Offline version:

  1. User writes post + picks image
  2. App saves draft locally
  3. Show "Will post when online" message
  4. When online, upload image, create post, sync
data class PostDraft(
    val id: String = UUID.randomUUID().toString(),
    val content: String,
    val imagePath: String?,  // Path to image on device
    val createdAt: Long = System.currentTimeMillis()
)

class CreatePostViewModel @Inject constructor(
    private val postsRepository: PostsRepository,
    private val connectivity: ConnectivityManager
) : ViewModel() {
    fun publishPost(content: String, imagePath: String?) {
        viewModelScope.launch {
            if (connectivity.isOnline()) {
                // Online: publish immediately
                publishPostOnline(content, imagePath)
            } else {
                // Offline: save as draft
                publishPostOffline(content, imagePath)
            }
        }
    }

    private suspend fun publishPostOnline(content: String, imagePath: String?) {
        try {
            val imageUrl = if (imagePath != null) {
                val presignedUrl = postsRepository.getImageUploadUrl()
                uploadImage(imagePath, presignedUrl)
            } else {
                null
            }

            val newPost = postsRepository.createPost(content, imageUrl)
            _postCreationState.value = PostCreationState.Success(newPost)
        } catch (e: Exception) {
            _postCreationState.value = PostCreationState.Error(e.message ?: "Unknown error")
        }
    }

    private suspend fun publishPostOffline(content: String, imagePath: String?) {
        // Save as draft
        val draft = PostDraft(
            content = content,
            imagePath = imagePath
        )
        postsRepository.saveDraft(draft)
        _postCreationState.value = PostCreationState.SavedAsDraft(draft.id)

        // When online, a background sync will publish it
    }
}

When the user comes back online, a sync service publishes all drafted posts:

class SyncManager {
    private fun syncDraftPosts() {
        viewModelScope.launch {
            val drafts = postsRepository.getAllDrafts()

            for (draft in drafts) {
                try {
                    val imageUrl = if (draft.imagePath != null) {
                        uploadImageForDraft(draft.imagePath)
                    } else {
                        null
                    }

                    val newPost = postsRepository.createPost(draft.content, imageUrl)
                    postsRepository.deleteDraft(draft.id)
                } catch (e: Exception) {
                    // Retry later
                }
            }
        }
    }
}

Part 4: Image Uploads with Progress & Resume

Uploading large images is finicky. The network might fail halfway through a 5 MB upload.

Presigned URL Upload with Progress

class ImageUploader @Inject constructor(
    private val httpClient: OkHttpClient
) {
    suspend fun uploadImage(
        filePath: String,
        presignedUrl: String,
        onProgress: (progress: Int) -> Unit
    ): String {
        val file = File(filePath)

        val requestBody = object : RequestBody() {
            override fun contentType() = "image/jpeg".toMediaType()
            override fun contentLength() = file.length()

            override fun writeTo(sink: BufferedSink) {
                file.inputStream().use { input ->
                    val totalBytes = file.length()
                    val buffer = ByteArray(8192)
                    var uploadedBytes = 0L

                    while (true) {
                        val read = input.read(buffer)
                        if (read == -1) break

                        sink.write(buffer, 0, read)
                        uploadedBytes += read

                        val progress = (uploadedBytes * 100 / totalBytes).toInt()
                        onProgress(progress)
                    }
                }
            }
        }

        val request = Request.Builder()
            .url(presignedUrl)
            .put(requestBody)
            .build()

        val response = httpClient.newCall(request).execute()
        if (!response.isSuccessful) {
            throw IOException("Upload failed: ${response.code}")
        }

        return presignedUrl  // Return the URL where it was uploaded
    }
}

The ViewModel shows progress:

class CreatePostViewModel {
    fun publishPost(content: String, imagePath: String?) {
        viewModelScope.launch {
            try {
                imageUploader.uploadImage(
                    filePath = imagePath!!,
                    presignedUrl = presignedUrl,
                    onProgress = { progress ->
                        _uploadProgress.value = progress
                    }
                )
                // Rest of post creation...
            } catch (e: Exception) {
                _postCreationState.value = PostCreationState.Error(e.message)
            }
        }
    }
}

@Composable
fun CreatePostScreen(viewModel: CreatePostViewModel) {
    val uploadProgress by viewModel.uploadProgress.collectAsState()

    if (uploadProgress in 1..99) {
        LinearProgressIndicator(
            progress = uploadProgress / 100f,
            modifier = Modifier.fillMaxWidth()
        )
    }
}

Resumable Uploads

For very large files, uploads might fail halfway. Some cloud providers (like AWS S3) support resumable uploads using multipart uploads:

  1. Split file into chunks (5 MB each)
  2. Upload each chunk separately
  3. If chunk fails, retry just that chunk
  4. Combine all chunks at the end

This is complex, so we'll leave the details for a follow-up session. The key idea: don't require users to restart from scratch if an upload fails.

Part 5: Conflict Scenarios

Real systems have conflicts. Here are common ones:

Conflict 1: Post Was Deleted

User liked a post offline. Post gets deleted on the server. User comes online.

suspend fun syncLikePost(postId: String) {
    try {
        remoteDataSource.likePost(postId)
    } catch (e: HttpException) {
        if (e.code == 404) {  // Post not found
            // Clean up: remove the like from local database
            localDataSource.unlikePost(postId)
            // Show message to user
            showError("That post no longer exists.")
        }
    }
}

Conflict 2: Comment on Non-Existent Post

Same logic: handle 404 gracefully.

Conflict 3: Duplicate Like (User Spams Button)

User taps like 5 times really fast. Should we queue 5 "like" actions or just 1?

Answer: Just 1. If the user is already liking the post locally, ignore the second tap:

class PostsRepository {
    private val likeInProgress = mutableMapOf<String, Boolean>()  // postId -> in progress

    suspend fun likePost(postId: String) {
        // If already in progress, ignore
        if (likeInProgress[postId] == true) return

        likeInProgress[postId] = true

        try {
            localDataSource.likePost(postId, isLiked = true)
            try {
                remoteDataSource.likePost(postId)
            } catch (e: Exception) {
                syncQueue.enqueue(SyncAction.LikePost(postId, true))
            }
        } finally {
            likeInProgress[postId] = false
        }
    }
}

Part 6: Rich Content Rendering

Posts can have mixed content: text + image + video + links. The UI needs to handle all of it.

Post with Multiple Content Types

@Composable
fun PostCard(post: Post) {
    Column {
        // Author info
        Row {
            Image(
                painter = rememberAsyncImagePainter(post.author.profilePictureUrl),
                contentDescription = null,
                modifier = Modifier
                    .size(40.dp)
                    .clip(CircleShape)
            )
            Column {
                Text(post.author.name, fontWeight = FontWeight.Bold)
                Text(post.createdAt, fontSize = 12.sp, color = Color.Gray)
            }
        }

        // Post content
        Text(post.content)

        // Images
        if (post.imageUrls.isNotEmpty()) {
            LazyRow {
                items(post.imageUrls) { imageUrl ->
                    Image(
                        painter = rememberAsyncImagePainter(imageUrl),
                        contentDescription = null,
                        modifier = Modifier
                            .size(300.dp)
                            .clip(RoundedCornerShape(8.dp))
                    )
                }
            }
        }

        // Link preview (if post contains a URL)
        post.linkPreview?.let { link ->
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(8.dp),
                border = BorderStroke(1.dp, Color.LightGray)
            ) {
                Row(modifier = Modifier.padding(8.dp)) {
                    Image(
                        painter = rememberAsyncImagePainter(link.thumbnailUrl),
                        contentDescription = null,
                        modifier = Modifier.size(80.dp)
                    )
                    Column {
                        Text(link.title, fontWeight = FontWeight.Bold)
                        Text(link.description, maxLines = 2)
                    }
                }
            }
        }

        // Interaction buttons
        Row(modifier = Modifier.fillMaxWidth()) {
            IconButton(
                onClick = { /* like */ }
            ) {
                Icon(
                    Icons.Default.Favorite,
                    contentDescription = "Like",
                    tint = if (post.isLikedByCurrentUser) Color.Red else Color.Gray
                )
            }
            Text("${post.likeCount}")

            IconButton(onClick = { /* comment */ }) {
                Icon(Icons.Default.Comment, contentDescription = "Comment")
            }
            Text("${post.commentCount}")
        }
    }
}

Part 7: Accessibility

Social feeds need to be accessible to all users:

@Composable
fun PostCard(post: Post) {
    Column(
        modifier = Modifier.semantics(mergeDescendants = true) {}
    ) {
        Image(
            painter = rememberAsyncImagePainter(post.imageUrls[0]),
            contentDescription = "Image from ${post.author.name}'s post",  // Important!
            modifier = Modifier.size(300.dp)
        )

        IconButton(
            onClick = { /* like */ },
            modifier = Modifier
                .size(48.dp)  // Large touch target
                .semantics {
                    contentDescription = if (post.isLikedByCurrentUser) "Unlike this post" else "Like this post"
                }
        ) {
            Icon(Icons.Default.Favorite, contentDescription = null)
        }
    }
}

🎯 Interviewer's Perspective: Asking about offline support and optimistic updates shows maturity. Many junior candidates ignore offline. Mentioning it shows you've shipped real apps.

Common Pitfall: Forgetting to Revert Optimistic Updates

Some developers do optimistic updates but forget what happens if the server rejects:

❌ Bad:

fun likePost(postId: String) {
    localDataSource.likePost(postId, true)  // Optimistic
    remoteDataSource.likePost(postId)  // Forgot to handle error!
}

✓ Good:

fun likePost(postId: String) {
    localDataSource.likePost(postId, true)  // Optimistic
    try {
        remoteDataSource.likePost(postId)  // Sync
    } catch (e: Exception) {
        localDataSource.likePost(postId, false)  // Revert!
        throw e
    }
}

🔗 Building on Previous: The sync queue and offline support build on the Repository pattern from the architecture section. The Repository is where we handle these conflicts and retries.

Summary

Offline support and optimistic updates make apps feel magical:

  1. Show changes immediately (optimistic)
  2. Sync quietly in the background
  3. Revert if the server rejects
  4. Queue actions to retry later

It's harder to build, but users absolutely notice and love it.

Next: We've covered the deep dives. Time to wrap up and summarize everything.

Back to Mobile System Design Interviews