androidengineers.Book a session

Case Study: Design Instagram

DM & Search/Discovery

article20 minHard

Direct messaging, search, and content discovery are three distinct systems that share a theme: low-latency, high-relevance results. Each requires different architecture choices.

Direct Messages (DMs)

Conversation List

data class ConversationPreview(
    val conversationId: String,
    val partnerName: String,
    val partnerAvatarUrl: String,
    val lastMessage: String,
    val lastMessageAt: Long,
    val unreadCount: Int,
    val isOnline: Boolean
)

// Conversation list from Room, synced via WebSocket
class ConversationListViewModel @Inject constructor(
    private val repository: ConversationRepository,
    private val presenceService: PresenceService
) : ViewModel() {

    val conversations: StateFlow<List<ConversationPreview>> =
        repository.observeConversations()
            .combine(presenceService.onlineUsers) { convs, onlineIds ->
                convs.map { it.copy(isOnline = it.partnerId in onlineIds) }
            }
            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}

Message Threading

@Entity(tableName = "messages")
data class MessageEntity(
    @PrimaryKey val id: String,
    val conversationId: String,
    val senderId: String,
    val text: String?,
    val mediaUrl: String?,
    val mediaType: MediaType?,
    val sentAt: Long,
    val deliveredAt: Long?,
    val readAt: Long?,
    val status: MessageStatus  // SENDING, SENT, DELIVERED, READ, FAILED
)

// Date separator in UI
sealed class MessageListItem {
    data class DateHeader(val date: LocalDate) : MessageListItem()
    data class Message(val entity: MessageEntity) : MessageListItem()
}

fun List<MessageEntity>.toListItems(): List<MessageListItem> {
    val items = mutableListOf<MessageListItem>()
    var lastDate: LocalDate? = null

    sortedBy { it.sentAt }.forEach { msg ->
        val date = Instant.ofEpochMilli(msg.sentAt).atZone(ZoneId.systemDefault()).toLocalDate()
        if (date != lastDate) {
            items.add(MessageListItem.DateHeader(date))
            lastDate = date
        }
        items.add(MessageListItem.Message(msg))
    }
    return items
}

Search Architecture

Debounced Search with Room FTS

@Dao
interface SearchDao {
    // Full-text search on post captions and user names
    @Query("""
        SELECT * FROM posts_fts
        WHERE posts_fts MATCH :query
        ORDER BY rank
        LIMIT 20
    """)
    fun searchPosts(query: String): Flow<List<PostEntity>>

    @Query("""
        SELECT * FROM users_fts
        WHERE users_fts MATCH :query
        ORDER BY follower_count DESC
        LIMIT 10
    """)
    fun searchUsers(query: String): Flow<List<UserEntity>>
}

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val dao: SearchDao,
    private val api: SearchApi
) : ViewModel() {

    private val _query = MutableStateFlow("")
    val query: StateFlow<String> = _query

    val searchResults: StateFlow<SearchResults> = _query
        .debounce(300)
        .filter { it.length >= 2 }
        .flatMapLatest { q ->
            combine(
                dao.searchUsers(q),
                dao.searchPosts(q),
                api.searchHashtags(q).catch { emit(emptyList()) }
            ) { users, posts, hashtags ->
                SearchResults(users = users, posts = posts, hashtags = hashtags)
            }
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SearchResults())

    fun onQueryChanged(q: String) { _query.value = q }
}

Remote Search for Fresh Results

// Hybrid: local FTS for instant results, remote API for fresh/ranked results
val searchResults: Flow<SearchResults> = _query
    .debounce(300)
    .filter { it.length >= 2 }
    .flatMapLatest { q ->
        flow {
            // Emit local results immediately
            val local = dao.searchUsers(q).first()
            emit(SearchResults(users = local, isLocal = true))

            // Then fetch from server
            try {
                val remote = api.search(q)
                emit(SearchResults(users = remote.users, posts = remote.posts, isLocal = false))
            } catch (e: IOException) {
                // Keep local results; don't error
            }
        }
    }

Discovery Feed

Explore / Discover Algorithm (Client Perspective)

data class DiscoverPost(
    val post: Post,
    val reason: DiscoverReason   // why this was shown
)

enum class DiscoverReason {
    TRENDING,       // high engagement in last hour
    SIMILAR_TO_LIKED,  // similar content to posts you liked
    FOLLOWED_HASHTAG,  // matches a hashtag you follow
    POPULAR_IN_AREA    // geographically trending
}

// Display reason hint in UI
@Composable
fun DiscoverPostHeader(reason: DiscoverReason) {
    val text = when (reason) {
        DiscoverReason.TRENDING -> "Trending now"
        DiscoverReason.SIMILAR_TO_LIKED -> "Because you liked similar posts"
        DiscoverReason.FOLLOWED_HASHTAG -> "From a hashtag you follow"
        DiscoverReason.POPULAR_IN_AREA -> "Popular near you"
    }
    Text(text, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}

Deduplicate Seen Posts

class SeenPostsTracker(private val prefs: DataStore<Preferences>) {
    private val seenKey = stringSetPreferencesKey("seen_post_ids")

    suspend fun markSeen(postId: String) {
        prefs.edit { it[seenKey] = (it[seenKey] ?: emptySet()) + postId }
    }

    suspend fun filterUnseen(posts: List<Post>): List<Post> {
        val seen = prefs.data.first()[seenKey] ?: emptySet()
        return posts.filter { it.id !in seen }
    }
}

Key Takeaways

SystemKey Pattern
DM conversation listRoom + WebSocket; combine with presence service for online status
Message date headersTransform List<Message>List<MessageListItem> with DateHeader inserted
Search debounce300ms debounce + flatMapLatest → cancels in-flight search on new keystroke
Hybrid searchEmit local FTS results instantly; overlay remote results when they arrive
Discovery reasonsShow "because you liked..." — increases trust and engagement
DeduplicationTrack seen post IDs; filter from discovery feed

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
DM & Search/Discovery | Android System Design | Android Engineers