Free lesson · Mobile System Design Interviews
Social Feed App - Deep Dive: Offline Support & User Interactions
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:
- User taps "like" → Action happens immediately in UI (assume success)
- App queues the action → "Like this post when you're online"
- Network comes back → App syncs the queued action
- 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:
- User writes post + picks image
- App uploads image to cloud storage (presigned URL)
- App sends POST /posts with image URL
- Post appears in feed
Offline version:
- User writes post + picks image
- App saves draft locally
- Show "Will post when online" message
- 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:
- Split file into chunks (5 MB each)
- Upload each chunk separately
- If chunk fails, retry just that chunk
- 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:
- Alt text for images: Every image should have a description for screen readers
- Color contrast: Like button must be visible even for colorblind users
- Touch targets: Buttons should be at least 48 x 48 dp
- Content ordering: Screen readers should read posts in logical order
@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:
- Show changes immediately (optimistic)
- Sync quietly in the background
- Revert if the server rejects
- 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.