androidengineers.Book a session

Case Study: Design Twitter/X + Interview Framework

Notifications & Delivery Guarantees

article20 minHard

Twitter-scale notifications must be delivered reliably across different device states (foreground, background, killed), battery optimization regimes, and network conditions. Getting this right requires understanding FCM's delivery modes and Android's doze/standby constraints.

FCM Delivery Modes

// HIGH priority (bypasses Doze; battery impact)
// Use for: DMs, @mentions, follows — time-critical alerts

// NORMAL priority (queued during Doze; ~15-minute delay)  
// Use for: likes, retweets, weekly digests — non-urgent

// Data message vs notification message:
// Notification message: FCM shows it automatically (OS handles display)
// Data message: your code handles display (full control, required for custom behavior)

Data Message Handler

class TwitterFirebaseMessagingService : FirebaseMessagingService() {

    override fun onMessageReceived(message: RemoteMessage) {
        val type = message.data["notification_type"] ?: return

        when (NotificationType.fromString(type)) {
            NotificationType.DIRECT_MESSAGE -> handleDm(message.data)
            NotificationType.MENTION -> handleMention(message.data)
            NotificationType.LIKE -> handleLike(message.data)
            NotificationType.FOLLOW -> handleFollow(message.data)
            NotificationType.RETWEET -> handleRetweet(message.data)
        }
    }

    private fun handleDm(data: Map<String, String>) {
        val conversationId = data["conversation_id"] ?: return
        val senderName = data["sender_name"] ?: return
        val preview = data["preview"] ?: return

        // Update local Room database with new message info
        WorkManager.getInstance(this).enqueue(
            OneTimeWorkRequestBuilder<FetchDmWorker>()
                .setInputData(workDataOf("conversation_id" to conversationId))
                .setConstraints(Constraints.Builder()
                    .setRequiredNetworkType(NetworkType.CONNECTED)
                    .build())
                .build()
        )

        // Show notification immediately with preview (without waiting for full sync)
        showDmNotification(conversationId, senderName, preview)
    }

    private fun showDmNotification(conversationId: String, senderName: String, preview: String) {
        val intent = Intent(this, MainActivity::class.java).apply {
            putExtra("destination", "conversation")
            putExtra("conversation_id", conversationId)
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
        }

        val notification = NotificationCompat.Builder(this, "direct_messages")
            .setSmallIcon(R.drawable.ic_dm)
            .setContentTitle(senderName)
            .setContentText(preview)
            .setContentIntent(PendingIntent.getActivity(
                this, conversationId.hashCode(), intent,
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
            ))
            .setAutoCancel(true)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setCategory(NotificationCompat.CATEGORY_MESSAGE)
            .build()

        NotificationManagerCompat.from(this).notify(conversationId.hashCode(), notification)
    }
}

Handling Device Token Refresh

class TwitterMessagingService : FirebaseMessagingService() {

    override fun onNewToken(token: String) {
        // Must register new token with your server
        WorkManager.getInstance(this).enqueue(
            OneTimeWorkRequestBuilder<RegisterTokenWorker>()
                .setInputData(workDataOf("token" to token))
                .setConstraints(Constraints.Builder()
                    .setRequiredNetworkType(NetworkType.CONNECTED)
                    .build())
                .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
                .build()
        )
    }
}

Notification Grouping

// Group multiple notifications from the same category
fun showLikeNotification(likeCount: Int, latestLiker: String, tweetId: String) {
    val GROUP_KEY = "twitter_likes"

    if (likeCount == 1) {
        // Single notification
        NotificationManagerCompat.from(context).notify(tweetId.hashCode(),
            NotificationCompat.Builder(context, "likes")
                .setContentTitle("$latestLiker liked your tweet")
                .setSmallIcon(R.drawable.ic_like)
                .setGroup(GROUP_KEY)
                .build()
        )
    } else {
        // Summary notification (groups the individual ones)
        NotificationManagerCompat.from(context).notify(GROUP_KEY.hashCode(),
            NotificationCompat.Builder(context, "likes")
                .setContentTitle("$likeCount people liked your tweets")
                .setSmallIcon(R.drawable.ic_like)
                .setGroup(GROUP_KEY)
                .setGroupSummary(true)
                .setStyle(NotificationCompat.InboxStyle()
                    .addLine("$latestLiker and ${likeCount - 1} others liked your tweet")
                    .setSummaryText("$likeCount likes")
                )
                .build()
        )
    }
}

Delivery Guarantee Patterns

At-Least-Once with Deduplication

FCM can deliver a message multiple times (e.g., during network instability). Your handler must be idempotent:

class NotificationDeduplicator(private val prefs: DataStore<Preferences>) {
    private val processedKey = stringSetPreferencesKey("processed_notification_ids")

    suspend fun processOnce(messageId: String, action: suspend () -> Unit) {
        val processed = prefs.data.first()[processedKey] ?: emptySet()
        if (messageId in processed) return  // already processed

        action()

        prefs.edit {
            val current = it[processedKey] ?: emptySet()
            it[processedKey] = (current + messageId).takeLast(1000).toSet()
        }
    }
}

Key Takeaways

PatternRule
HIGH vs NORMAL priorityDM/mention → HIGH (bypass Doze); likes → NORMAL (can wait)
Data messagesUse for custom display logic; notification messages for simple alerts
Token refresh via WorkManagerRetry on network failure; losing the token = lost notifications
Notification groupingsetGroup + summary notification for multiple alerts of same type
Idempotent handlerFCM may deliver twice; deduplicate by message ID
Permission checkPOST_NOTIFICATIONS required on API 33+; request at first-login

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Notifications & Delivery Guarantees | Android System Design | Android Engineers