Uber Interview Question

Design a Notification Library (Android) — Uber Interview

hard22 minAndroid System Design

How Uber Tests This

Uber interviews focus on real-time location tracking, ride-matching algorithms, delivery logistics, geospatial systems, and mobile architecture. Both backend and Android system design questions are common for senior roles.

Interview focus: Real-time location, geospatial systems, delivery logistics, mobile architecture, and monitoring.

Key Topics
notificationsfcmbroadcastreceivernotificationmanagerpersistent connectionlocal notificationsin app notificationskotlin

Android System Design: Design a Notification Library

"Design a notification library" sounds like a narrow problem. Pull the thread a little and it expands fast.

Remote push from a server. Local scheduled notifications. An in-app notification center. Channel management across Android versions. A runtime permission that silently drops your notifications if you forget to check it. A BroadcastReceiver that has milliseconds to complete its work before the OS can kill it.

This question is interesting at senior level because it's really two problems at once: platform knowledge — understanding how Android's notification stack works — and library design — building an abstraction that hides that complexity behind an API other engineers actually want to use.

This guide keeps both in focus. Every platform component gets explained, grounded in how it shapes a decision in the library we're building, and illustrated with the kind of back-and-forth you'd actually have with an interviewer.


Step 1: Clarify the Scope

Spend the first two minutes here. The question is genuinely ambiguous.

Interviewer: So, let's start. Design a notification library for Android.

Candidate: Before I jump in — a few clarifying questions. Is this library for internal use across a company's apps, or an SDK for third-party developers? That changes how tightly I'd lock down the public API. And which notification types are in scope — remote push via FCM, local scheduled notifications, an in-app inbox, or all three? Do we need analytics, like delivery tracking and open rates? And should the library handle the POST_NOTIFICATIONS permission request, or leave that to the host app?

Interviewer: Good questions. Assume it's an internal library used across multiple apps in the same company. Support all three notification types. Analytics is a nice-to-have. Permission handling — your call.

Candidate: Got it. I'll design for remote push, local scheduling, and an in-app inbox as first-class features. For the permission request, I'll provide the mechanism inside the library but leave the rationale UI to the host app — libraries that impose their own UI tend to clash with the host app's design language. Let me start with requirements and then walk through the architecture.


Requirements

Functional

  • Receive and display remote push notifications in all app states — foreground, background, killed
  • Schedule local notifications without a server
  • Maintain an in-app inbox with read/unread state and history
  • Support per-category notification preferences
  • Deep link from a notification tap to the correct screen
  • Track delivery, opens, and dismissals

Non-Functional

  • Simple API — consumers should integrate in minutes, not hours
  • OS complexity hidden internally — callers never write an Android version check
  • Battery-safe — persistent connections and background work must not drain the battery
  • Thread-safe — callable from multiple threads without race conditions
  • Minimal footprint — no heavy transitive dependencies

The Three Notification Types

Interviewer: Before you go further — what do you mean by "three notification types"? Aren't they all just... notifications?

Candidate: They look the same to the user, but they're completely different systems under the hood. Remote push is delivered by FCM via a persistent connection — the server initiates it, and it works even when the app is killed. Local notifications are triggered by the device on a schedule you define — no server needed, using AlarmManager or WorkManager. And the in-app inbox isn't an OS notification at all — it's just a list of records stored in Room and displayed inside a screen in the app. Think the notification bell in Instagram. A good library unifies all three behind one API so the caller never has to think about which mechanism is being used.


Library Architecture

plaintext
┌──────────────────────────────────────────┐
│            Public API Layer              │
│  NotificationLibrary (init + config)     │
│  NotificationBuilder (fluent API)        │
│  ChannelRegistry                         │
│  NotificationPermissionManager           │
└──────────────────┬───────────────────────┘

┌──────────────────▼───────────────────────┐
│          Core Processing Layer           │
│  NotificationDispatcher                  │
│  PreferenceManager                       │
│  AnalyticsTracker                        │
└───────┬──────────────────┬───────────────┘
        │                  │
┌───────▼───────┐  ┌───────▼──────────────┐
│  Push Handler │  │  Local Scheduler      │
│  FCM pipeline │  │  AlarmManager/Worker  │
└───────┬───────┘  └───────────────────────┘

┌───────▼───────────────────────────────────┐
│          Android Platform Layer           │
│  NotificationManagerCompat               │
│  BroadcastReceivers (3 roles)            │
│  Room DB (inbox + scheduled + analytics) │
└───────────────────────────────────────────┘

The defining structural decision is a single entry point. Consumers interact with NotificationBuilder and call .show(). Whether that notification gets delivered via FCM, posted via AlarmManager, or suppressed because the user is already on that screen — is the library's problem, not the caller's. The NotificationDispatcher routes internally.


Public API: The Most Important Design Decision

Interviewer: Walk me through the public API. What does a consumer actually write to use this library?

Candidate: The library is initialised once in Application.onCreate():

plaintext
NotificationLibrary.init(
    context = this,
    config = NotificationConfig.Builder()
        .defaultChannel(ChannelType.GENERAL)
        .analyticsEnabled(true)
        .smallIcon(R.drawable.ic_notification)
        .inboxRetentionDays(30)
        .build()
)

Then, showing a notification from anywhere in the app looks like this:

plaintext
NotificationLibrary.builder(context)
    .channel(ChannelType.MESSAGES)
    .title("New message from Alice")
    .body("Are you free tonight?")
    .deepLink("myapp://conversation/123")
    .priority(Priority.HIGH)
    .show()

Interviewer: Why a Builder pattern? And why ChannelType as an enum?

Candidate: Builder pattern because a notification has too many optional parameters for a constructor to be readable. Each field is explicit and self-documenting. The ChannelType enum is a deliberate API decision — if I exposed a raw String, a typo becomes a silent runtime failure where the notification is posted to a non-existent channel. An enum makes invalid channels a compile error. The library defines the enum; it maps to real NotificationChannel instances registered during init().

Interviewer: What does .show() return?

Candidate: A sealed NotificationResult — either Success or Error(reason). If POST_NOTIFICATIONS is denied, the caller gets Error(PERMISSION_DENIED) rather than a silent drop. If the target channel is disabled by the user, they get Error(CHANNEL_DISABLED). The caller decides how to respond. Silent failures in a notifications library are the worst kind of bug — the user just never sees a notification and nobody knows why.

Interviewer: How do you handle testability? A singleton makes mocking hard.

Candidate: The library exposes NotificationLibrary as a singleton for simplicity — no DI setup required. But the core functionality is backed by a NotificationProvider interface. Teams using Hilt or Koin inject the interface and can mock it freely. Everyone else uses the singleton. This is the same pattern WorkManager and Glide follow. You get both — easy integration and testability.


Remote Push Notifications

This is where most of the interview depth lives. Let's trace the full journey from server to status bar.

FCM and the Persistent Connection

What it is: Firebase Cloud Messaging is Google's cross-platform message delivery service. It delivers messages to devices at any time — even when the app isn't running.

How it works: FCM doesn't rely on your app to maintain a connection. Google Play Services maintains a single, shared TCP socket to FCM's servers on behalf of every app on the device. Play Services has elevated OS privileges and is never killed by Android's normal memory management. When a message arrives for your app, Play Services wakes the process and delivers the payload.

Interviewer: So the library maintains a persistent connection for push delivery?

Candidate: No — and this is an important distinction. The library doesn't maintain any connection at all. Play Services owns the persistent TCP socket and keeps it alive on behalf of every app on the device. Our library pays zero battery cost to be reachable. We just register a FirebaseMessagingService subclass and wait to be called. The connection complexity is entirely Google's problem.

Interviewer: What about Doze mode? Won't Android kill that connection?

Candidate: FCM high-priority messages are explicitly exempt from Doze. Android carves out this exception specifically for messaging apps. The library inherits it for free — no special handling required. That's one of the strongest reasons to use FCM over rolling your own persistent connection.

The full push delivery path:

plaintext
[Your Server]
     │  HTTPS POST to FCM API

[FCM Backend (Google)]


[Play Services — shared persistent TCP socket, Doze-exempt]
     │  wakes app process when message arrives

[Library's PushMessageService.onMessageReceived()]


[NotificationDispatcher — library takes over here]

Data Messages vs. Notification Messages

What they are: FCM offers two message types. Notification messages tell the FCM SDK to display a notification automatically, before your app's code runs. Data messages route the payload to your onMessageReceived() callback first — in all app states — leaving rendering entirely to the app.

Interviewer: Which one should the library use?

Candidate: Data messages, always. With notification messages, the library is bypassed the moment they land. The OS renders them before onMessageReceived() fires. We lose the ability to suppress the notification if the user is already on that screen, we can't write to the in-app inbox, we can't apply MessagingStyle grouping, and we can't track delivery. For a library that claims to own notification management, notification messages are a non-starter. The library must require the server team to send data messages — it's the first contract in our documentation.

The payload schema the library defines:

json
{
  "data": {
    "notification_id": "uuid",
    "channel": "MESSAGES",
    "title": "New message from Alice",
    "body": "Are you free tonight?",
    "deep_link": "myapp://conversation/123",
    "priority": "HIGH",
    "ttl": "86400"
  }
}

Every field maps to a decision the NotificationDispatcher makes. channel maps to a NotificationChannel. deep_link becomes the PendingIntent. ttl tells FCM when to stop retrying.

FirebaseMessagingService: The Library's Entry Point

What it is: FirebaseMessagingService is an Android service the FCM SDK calls when a push message arrives. It has two key callbacks: onMessageReceived() for incoming messages, and onNewToken() when the device's FCM registration token changes.

How the library uses it: the library ships its own PushMessageService that extends FirebaseMessagingService, declared in the library's merged manifest. The host app writes no FCM code — it just calls init().

Interviewer: What happens inside onMessageReceived()?

Candidate: The first thing to know is that onMessageReceived() runs on the main thread with a tight execution window. If we do meaningful work synchronously, the OS can kill the process before it finishes. So we hand off to a coroutine on the IO dispatcher immediately. The sequence inside the dispatcher is:

plaintext
PushMessageService.onMessageReceived(remoteMessage)
  → launch on IO dispatcher
      → parse and validate payload
      → write record to Room inbox (always — before anything else)
      → check PreferenceManager: is this channel enabled?
      → send ordered broadcast to check if user is on target screen
      → if notification should be shown:
            → build Notification via NotificationBuilder
            → post via NotificationManagerCompat
            → write DELIVERED event to analytics table

The inbox record is always written first. Even if the OS notification ends up suppressed or the channel is disabled, that record exists in the in-app center for the user to find.

FCM Token Management

What it is: when a device registers with FCM, it gets a unique registration token — the address the server uses to route messages to that specific device. Tokens can change on reinstall, after clearing app data, or when FCM rotates them.

Interviewer: How does the library handle token changes?

Candidate: onNewToken() fires whenever the token changes. Our PushMessageService catches it and enqueues a WorkManager task with a network constraint to sync the new token to the backend. We use WorkManager rather than a direct API call because the device might be offline when the token refreshes. The sync needs to survive that. The token itself goes into EncryptedSharedPreferences — not plain SharedPreferences. A stale token means that device silently stops receiving pushes, and it's one of the hardest bugs to diagnose in production.


BroadcastReceiver: The Library's Async Backbone

What it is: BroadcastReceiver is an Android component that receives and responds to asynchronous system or app-level events. It can be triggered even when no other component of your app is running. Android uses it for system events like device boot and network changes. Apps use it for AlarmManager alarms, notification actions, and more.

How the library uses it: the library registers three distinct BroadcastReceiver implementations, each handling a different async seam in the notification pipeline.

Interviewer: Where does BroadcastReceiver come into a notification library?

Candidate: In three places. First, for scheduled local notifications — AlarmManager doesn't call a method directly, it fires a broadcast. Second, for dismissal tracking — when a user swipes a notification away, the only hook Android gives you is a broadcast via deleteIntent. Third, for notification action buttons — when a user taps "Mark as read" or "Reply", that fires as a broadcast too. The library has a separate receiver for each of these.

Role 1: Scheduled Alarm Delivery

plaintext
AlarmManager fires at the scheduled time
  → Android sends broadcast to ScheduledNotificationReceiver.onReceive()
  → receiver fetches the notification record from Room by ID
  → passes it to NotificationDispatcher — same pipeline as push
  → notification is built and posted via NotificationManagerCompat

Interviewer: Why fetch from Room rather than embedding the payload in the Intent?

Candidate: Two reasons. First, Intent size limits — if the payload is large, you risk a TransactionTooLargeException. Second, if the notification content was updated between scheduling and firing, the Room fetch gives us the latest version. Embedding in the Intent locks you into whatever the content was at schedule time.

Role 2: Dismissal Tracking

Interviewer: How do you know when a user dismisses a notification?

Candidate: NotificationManagerCompat has no callback for this. The only hook is the notification's deleteIntent — a PendingIntent Android fires when the user swipes the notification away. Every notification the library posts has a broadcast PendingIntent set as the deleteIntent, pointing at our NotificationDismissReceiver:

plaintext
User swipes notification away
  → Android fires deleteIntent broadcast
  → NotificationDismissReceiver.onReceive()
  → writes DISMISSED event to analytics Room table
  → updates inbox record (dismissed ≠ read — both states tracked separately)

Because the library sets this up on every notification it posts, consumers never wire it up themselves. It's automatic.

Role 3: Notification Action Buttons

plaintext
User taps "Mark as Read" action
  → Android fires action PendingIntent
  → NotificationActionReceiver.onReceive()
  → marks Room inbox record as read
  → cancels the OS notification via NotificationManagerCompat
  → writes READ event to analytics table

This receiver is declared in the library's manifest with an app-level permission attribute so only the host app's process can broadcast these intents — other apps on the device can't spoof notification actions.

The Critical Constraint: onReceive() Must Return Fast

Interviewer: What's the threading model inside a BroadcastReceiver?

Candidate: onReceive() runs on the main thread. Android expects it to return quickly. If we start a coroutine and onReceive() returns, the OS can kill the process before that coroutine finishes — because from Android's perspective, there's no active component keeping the process alive. The library uses goAsync() inside every onReceive(). This requests a brief execution window extension from the OS. We complete the work on a coroutine tied to the extension's PendingResult, then call finish() when done. This is a platform detail the library encapsulates entirely — consumers never think about it.

Ordered Broadcast: Foreground Suppression

What it is: Android supports ordered broadcasts — a broadcast chain where receivers are called in priority order, and any receiver can call abortBroadcast() to stop the chain.

Interviewer: How would you suppress a notification if the user is already on the relevant screen?

Candidate: One approach is a global CurrentScreenTracker singleton that gets updated in onResume and onPause. Simple to implement, but it couples the library to the host app's lifecycle. I'd prefer ordered broadcasts. When a push arrives, the library sends an ordered broadcast. UI components that want to suppress the OS notification when they're visible register a local receiver in onResume and unregister in onPause. If that receiver catches the broadcast and calls abortBroadcast(), the status bar notification is never posted. The library's fallback receiver at priority 0 handles the default case:

plaintext
Library receives push → sends ordered broadcast

  ├── [Priority 100] ConversationActivity's local receiver
  │     User is in this conversation → abortBroadcast()
  │     OS notification suppressed. Inbox record already written.

  └── [Priority 0] Library's DefaultNotificationReceiver
        No screen intercepted → posts OS notification normally

The library doesn't need to know anything about the host app's screen structure. UI components opt in to suppression independently.


Android NotificationManager: What the Library Wraps

What it is: NotificationManager is the Android system service that renders notifications on the status bar, manages their lifecycle, and enforces user-configured channel settings. NotificationManagerCompat from AndroidX is the backward-compatible wrapper — it handles API-level differences transparently, without manual version checks.

How the library uses it: NotificationManagerCompat is a private implementation detail. Consumers never call it directly. Every notify(), cancel(), and createNotificationChannel() call goes through the library's NotificationDispatcher.

Interviewer: What does the library do before calling notify()?

Candidate: Two permission checks. First, global: NotificationManagerCompat.areNotificationsEnabled() — has the user disabled all notifications for this app? Second, per-channel: the effective importance of the target channel via NotificationManagerCompat.getNotificationChannel(channelId). Even if global notifications are on, a specific channel can be disabled. If either is blocked, the library returns an error rather than silently dropping the notification. Most implementations skip the per-channel check, and it surfaces as a subtle production bug — the user globally has notifications on, but one specific channel got quietly turned off.

Stable Notification IDs for Update-in-Place

Interviewer: What if the server sends an update to an existing notification?

Candidate: NotificationManagerCompat.notify() takes an integer ID. If you call it with the same ID twice, the second replaces the first — no duplicate in the drawer. The library derives notification IDs deterministically from the notification_id in the payload using hashCode(). So if the server sends a push to update "Your order has been placed" to "Your order is out for delivery", the library automatically updates the notification in-place. Consumers never think about notification IDs.

NotificationCompat: Why Not the Framework Builder?

Interviewer: You keep saying NotificationCompat — why not use the framework's Notification.Builder directly?

Candidate: NotificationCompat.Builder from AndroidX handles API-level differences transparently. Features that only exist on newer APIs — like MessagingStyle from API 24 — are silently ignored on older devices rather than crashing. If the library used the framework builder, it would need manual version checks everywhere. NotificationCompat removes all of that. The library uses it exclusively, so consumers get backward-compatible notifications across the full supported API range without any extra work.

Auto-Grouping

Interviewer: What happens if five notifications from the same channel arrive in quick succession?

Candidate: Without grouping, you'd flood the notification drawer with five separate cards. The library tracks active notification IDs per channel in Room. When the count exceeds a threshold — default is 3 — it rebuilds the group:

plaintext
4th MESSAGES notification arrives
  → library queries Room: which MESSAGES notifications are currently active?
  → assigns each .setGroup("channel_MESSAGES")
  → builds a summary notification with InboxStyle listing all senders
  → posts summary with .setGroupSummary(true) and the same group key
  → status bar collapses all four into one expandable group

Consumers just call .show(). The grouping logic is invisible to them.


Notification Channels: A Library-Level Design Decision

What they are: NotificationChannel (introduced in Android 8.0, API 26) lets users control which of your notifications they receive. Every notification must be assigned to a channel. Users manage channels individually in Android Settings — enabling or disabling them, setting importance and sound per channel.

How the library uses them: channel registration happens inside init(). The ChannelRegistry calls NotificationManagerCompat.createNotificationChannels() on every launch — this is idempotent. Channels are exposed as the ChannelType enum so callers can never reference one that doesn't exist.

The default set:

ChannelTypeUser-visible NameImportancePurpose
TRANSACTIONALOrders & PaymentsHIGHReceipts, OTPs
MESSAGESMessagesHIGHDirect messages
REMINDERSRemindersDEFAULTUser-set reminders
MARKETINGPromotionsLOWOffers, newsletters
SYSTEMApp UpdatesMINSilent background info

Interviewer: Why not just use one channel for everything? It's simpler.

Candidate: Because the user can only disable channels, not individual notifications. One channel means "disable all or nothing." If a user gets annoyed by promotional pushes and turns off the channel, they've also turned off payment receipts and security alerts — and they won't know until they miss something important. Granular channels let users make meaningful choices. Promotions off, payment receipts on — that's a reasonable preference, and our library should support it.

Interviewer: Can the library update a channel's importance level after it's been registered?

Candidate: No — and this is a hard platform constraint that shapes the library's design. Once a channel is registered with the OS, its importance level cannot be changed programmatically. If we registered MESSAGES as IMPORTANCE_LOW and later want IMPORTANCE_HIGH, the only path is deleting and recreating the channel with a new ID — and that loses all the user's channel-specific settings. This is why the library treats channel configuration as an immutable decision made at init() time. The documentation warns explicitly: importance levels are permanent after the first production release. If it were changeable, the ChannelRegistry could accept runtime updates. It can't.


Android 13: POST_NOTIFICATIONS Permission

What it is: from Android 13 (API 33), posting notifications is an opt-in runtime permission — POST_NOTIFICATIONS. Before Android 13, apps could post freely and users could disable in Settings. That model flipped. Without an explicit user grant, every call to notify() is silently dropped. No exception, no log — the notification just never appears.

Interviewer: How does the library handle the POST_NOTIFICATIONS permission?

Candidate: In three places. First, the manifest — the library's AndroidManifest.xml declares the permission. Because Android merges library manifests with the host app's at build time, the host app doesn't declare it separately. Second, permission gating — every path through the NotificationDispatcher that ends in a notify() call passes through PermissionManager.isGranted() first. Third, the request flow — the library exposes NotificationPermissionManager.requestPermission(activity, callback) wrapping the system dialog. The rationale UI is intentionally left to the host app.

Interviewer: Why leave the rationale UI to the host app?

Candidate: Because a library that shows its own dialog will inevitably clash with the host app's design language. Two different button styles, two different copy tones — it looks broken. The library provides the mechanism. The presentation is the host app's responsibility. Teams that want a pre-built rationale screen can add one; everyone else stays in control of their UX.

Interviewer: What about users upgrading from Android 12 to 13?

Candidate: If a user had notifications enabled before upgrading, the system automatically pre-grants POST_NOTIFICATIONS for existing apps. Only new installs on Android 13 and above need an explicit request. The library's PermissionManager checks the current Android version before attempting a permission request — on Android 12 and below, it's a no-op.


Local Notification Scheduling

The library exposes a unified Schedule API. Consumers describe what they want; the library picks the right tool:

plaintext
NotificationLibrary.schedule(
    notification = NotificationLibrary.builder(context)
        .channel(ChannelType.REMINDERS)
        .title("Daily Check-in")
        .build(),
    schedule = Schedule.repeating(
        firstFireAt = tomorrowAt9am,
        interval = Duration.ofHours(24)
    )
)

AlarmManager — For Exact Timing

What it is: AlarmManager lets apps schedule work at a precise time, even in low-power states. setExactAndAllowWhileIdle() fires alarms even during Doze.

Interviewer: When does the library use AlarmManager vs. WorkManager?

Candidate: AlarmManager for Schedule.exact() — when timing precision is genuinely user-visible. A calendar reminder, a medication alert, a countdown timer. From Android 12 this requires the SCHEDULE_EXACT_ALARMS permission. The library checks for it before scheduling. If it's not granted, we fall back to an inexact alarm and surface a warning — we don't crash or silently do nothing. WorkManager for Schedule.repeating() and Schedule.deferrable() — things like a daily digest or a re-engagement nudge where firing 15 minutes late is fine. WorkManager is battery-friendly, survives process kills, and doesn't need a special permission.

The Boot Receiver: A Detail Most Candidates Miss

Interviewer: What happens to scheduled notifications after the device restarts?

Candidate: AlarmManager alarms are wiped on device restart. WorkManager-based schedules survive because WorkManager persists its queue to disk and re-registers on boot automatically. But exact alarms don't. The library persists every scheduled notification to Room and registers a BootReceiver listening for ACTION_BOOT_COMPLETED. On every boot, the receiver fetches all pending exact-alarm notifications from Room and re-registers them with AlarmManager. From the consumer's perspective, a scheduled notification always fires at the time they specified — including after a restart. The library guarantees it internally.


In-App Notification Inbox

What it is: the in-app inbox is not an OS notification — it's a persistent list of notification records stored in Room and surfaced inside a screen in the app. Think the notification bell in Instagram or LinkedIn. Users tap it to see a history of recent activity, including things they might have dismissed from the status bar.

Interviewer: How does the inbox stay in sync with what was actually sent?

Candidate: Every notification the library handles — push, local, or direct show() call — writes a record to the Room notifications table before the OS notification is posted. Even if the OS notification gets suppressed or the channel is disabled, the inbox record always exists. The Room schema:

sql
notifications(
    id          TEXT PRIMARY KEY,
    channel     TEXT NOT NULL,
    title       TEXT NOT NULL,
    body        TEXT,
    deep_link   TEXT,
    is_read     INTEGER DEFAULT 0,
    created_at  INTEGER NOT NULL,
    expires_at  INTEGER
)

Index on (is_read, created_at DESC) — the most common query is "get all unread, newest first."

Interviewer: How does the unread badge count stay up to date?

Candidate: The library exposes a Flow<Int> backed by a Room live query: SELECT COUNT(*) FROM notifications WHERE is_read = 0. The host app's ViewModel observes this Flow. The badge updates automatically as records are inserted or marked read — no polling, no manual refresh calls needed.

Interviewer: What about inbox cleanup? Does it grow forever?

Candidate: A periodic WorkManager task constrained to requiresDeviceIdle(true) deletes records older than the retention window and any past their expires_at value. The default is 30 days, configurable via NotificationConfig. The library owns its own housekeeping — consumers never call a cleanup method.


Persistent Connection: FCM vs. Rolling Your Own

Interviewer: What if the app can't use FCM — say, it needs to run on devices without Google Play Services?

Candidate: Good edge case. I'd expose a PushProvider interface in the library — FCM is the default implementation, but consumers can swap in a custom one:

plaintext
interface PushProvider {
    fun connect()
    fun disconnect()
    fun onMessageReceived(callback: (payload: Map<String, String>) -> Unit)
}

For the custom implementation, the three realistic options are WebSocket, SSE, and MQTT.

Interviewer: Walk me through the trade-offs.

Candidate: WebSocket via OkHttp gives you full-duplex TCP, hosted in a Foreground Service. It's resilient through reconnection with exponential backoff, and it works for both push delivery and real-time data. The downside is battery cost — each app maintains its own socket — and you need a persistent notification for the Foreground Service.

SSE is simpler — it's a one-way HTTP stream, half-duplex. Works better through proxies and firewalls than WebSocket because it's HTTP-compatible. For a push-only notification library where the client doesn't need to send acknowledgements over the same connection, SSE is actually sufficient and cheaper than WebSocket.

MQTT is a lightweight pub/sub protocol over TCP, designed specifically for unreliable networks. It has built-in QoS levels for delivery guarantees and handles reconnection well. The catch is you need to run your own broker — Mosquitto, HiveMQ — which is infrastructure the team has to own.

Interviewer: What about Doze mode for all of these?

Candidate: None of them escape it. Without Play Services, any custom persistent connection gets suspended in Doze. You'd either need to request battery optimisation exemption from the user — which some OEMs hide deeply in Settings and users often decline — or accept that background delivery is delayed to maintenance windows. This is precisely why FCM exists. The library defaults to FCM and treats the PushProvider interface as an escape hatch for environments where that's genuinely not an option.


Deep Linking

What it is: a deep link is a URI that maps to a specific screen inside the app. On Android, deep links from notifications are implemented via PendingIntent — an intent the library creates and attaches to the notification, which Android fires when the user taps it.

Interviewer: How does the library handle deep linking?

Candidate: Consumers pass a URI string to .deepLink(). The library creates the PendingIntent internally. From Android 12, all PendingIntents must use FLAG_IMMUTABLE — we enforce this internally so consumers can't accidentally create mutable ones and get lint warnings or security flags. We use FLAG_UPDATE_CURRENT so re-posting the same notification ID updates the intent rather than leaving a stale one.

Interviewer: What about back navigation? If I tap a notification and land on a nested screen, pressing Back should go to the parent — not exit the app.

Candidate: TaskStackBuilder. The library uses it when the consumer provides a .backStack() URI. It synthesises the correct back navigation stack programmatically. The consumer just chains it:

plaintext
NotificationLibrary.builder(context)
    .deepLink("myapp://conversation/123")
    .backStack("myapp://inbox")
    .show()

Interviewer: How do you track notification opens?

Candidate: Every notification tap routes through a transparent NotificationTapActivity declared in the library's manifest. In onCreate() — before any layout renders, so the user sees nothing — it logs the OPENED analytics event, marks the inbox record as read, cancels the OS notification, and redirects to the real deep link destination. The user just lands where they expected to, with no visible intermediate step.


Analytics: The Library Tracks the Full Lifecycle

Interviewer: What analytics does the library track, and how?

Candidate: The full notification lifecycle — six events:

EventTriggerResponsible Component
DELIVEREDPush arrives and is processedPushMessageService
SCHEDULEDLocal notification queuedNotificationScheduler
SHOWNnotify() called successfullyNotificationDispatcher
OPENEDUser taps notificationNotificationTapActivity
DISMISSEDUser swipes awayNotificationDismissReceiver
SUPPRESSEDOrdered broadcast abortedNotificationDispatcher

Events are written to a Room analytics table and flushed to the backend via a periodic WorkManager task with a network constraint. One batch per flush window, not one network call per event — the same approach Firebase Analytics uses.

Interviewer: What if the host app wants to route analytics through their own system?

Candidate: The library exposes NotificationLibrary.analyticsEvents(): Flow<List<AnalyticsEvent>>. Host apps can observe this Flow and pipe events into whatever analytics system they already use — Mixpanel, Amplitude, their own backend. The library's built-in flush is opt-in.


User Preferences

Interviewer: How do per-category opt-outs work?

Candidate: The library stores preferences in EncryptedSharedPreferences keyed by ChannelType:

plaintext
notification_pref_MESSAGES   → true
notification_pref_MARKETING  → false

The NotificationDispatcher checks these before posting. But "disabled" means "don't interrupt me with an OS notification" — not "delete this from my history." Inbox records are still written for disabled channels. The user opted out of interruptions, not out of having a record.

Interviewer: Does the library need to tell the server about these preferences?

Candidate: Yes — and most implementations miss this. If the server isn't told, it keeps sending pushes for disabled categories. The device silently drops them, but FCM quota is wasted and the device processes messages that produce no value. When a preference changes, the library enqueues a WorkManager task to sync it to the backend. That sync step closes the loop.


Key Trade-offs to Raise

Interviewer: Let's zoom out. What are the biggest trade-offs in this design?

Candidate: A few I'd highlight:

Data messages vs. notification messages — notification messages are simpler for the server team. But the library is bypassed entirely: no suppression, no inbox, no analytics, no grouping. For a library that claims to own notification management, data messages are the only viable choice.

FCM vs. custom persistent connection — FCM is battery-free, Doze-exempt, and requires no infrastructure. Custom connections give independence from Play Services but introduce battery cost, Doze challenges, and a broker to run. Default to FCM, expose a PushProvider interface for environments that genuinely need an alternative.

AlarmManager vs. WorkManager — AlarmManager is precise but costly. WorkManager is cheap but inexact. The library exposes a Schedule abstraction and picks the right tool based on whether the consumer needs exact timing or can tolerate a delay.

Ordered broadcast vs. active screen flag — a CurrentScreenTracker singleton is simpler but couples the library to the host app's lifecycle. Ordered broadcast is more decoupled — UI components opt in independently. For a library, the decoupled pattern is always preferable.

Singleton vs. injectable interface — singleton is the easiest integration story, but makes mocking hard. Interface + singleton default gives both. Expose both.


Quick Interview Checklist

  • ✅ Clarified scope — push, local, in-app, or all three
  • ✅ Three notification types distinguished clearly before architecture
  • ✅ Single entry point — NotificationBuilder, all types unified behind it
  • init() registers channels, caches permission state, wires FCM token listener
  • ✅ Builder pattern with typed ChannelType enum — no raw strings in the public API
  • .show() returns sealed NotificationResult — no silent failures
  • ✅ Singleton + NotificationProvider interface — easy integration and testability
  • ✅ FCM explained — Play Services owns the connection, library just receives
  • ✅ Data messages required — notification messages bypass the library's pipeline entirely
  • onMessageReceived() on main thread — coroutine handoff immediately, inbox written first
  • ✅ BroadcastReceiver — three roles: alarm delivery, dismissal tracking, action handling
  • goAsync() in every receiver — OS can't kill the process mid-work
  • ✅ Ordered broadcast — foreground suppression without coupling to host app screens
  • ✅ NotificationManagerCompat fully wrapped — stable IDs, dual permission checks, auto-grouping
  • ✅ NotificationCompat over framework builder — backward-compatible across API levels
  • ✅ Channel strategy — importance levels are permanent after first registration
  • POST_NOTIFICATIONS — declared in library manifest, checked before every notify(), request UI owned by host app
  • ✅ Unified Schedule API — AlarmManager for exact, WorkManager for deferrable
  • ✅ Boot receiver re-registers exact alarms from Room after device restart
  • ✅ Inbox — always written before OS notification, Flow<Int> for unread count, idle WorkManager for eviction
  • ✅ Analytics — full lifecycle tracked in six events, batched flush via WorkManager
  • ✅ Preference sync to server — not just a local opt-out
  • ✅ FCM vs. WebSocket / SSE / MQTT — PushProvider abstraction, Doze trade-offs named

Conclusion

What makes this question rewarding at senior level is how every platform constraint ultimately bends back to a library design decision.

The shared FCM connection explains why the library pays no battery cost for push delivery. The BroadcastReceiver execution window explains why every receiver in the library uses goAsync(). Channel importance permanence explains why channel config is immutable at init() time. The POST_NOTIFICATIONS silent drop explains why the NotificationDispatcher gates every notify() call behind a permission check.

In the interview, the strongest answers don't just describe these things — they explain them as a chain: "here's what Android gives us, here's the constraint that comes with it, and here's the specific API decision it forces." That chain of reasoning is what interviewers at companies like Meta, Google, Snap, and Uber are actually listening for.

The design pillars:

  1. Single entry point with a Builder API — typed enums, no platform types leaking into the public surface
  2. FCM persistent connection — Play Services owns it; the library just receives
  3. Data messages always — notification messages bypass the library; data messages are the contract
  4. Three BroadcastReceiver roles — alarm delivery, dismissal tracking, action handling; ordered broadcast for foreground suppression
  5. NotificationManagerCompat fully wrapped — stable IDs, dual permission checks, auto-grouping; all internal
  6. Channel importance is permanent — get it right at init() time; document it as immutable
  7. Boot receiver for exact alarms — scheduled notifications survive reboots because the library re-registers them


Frequently Asked Questions

What is the difference between FCM data messages and notification messages?

FCM data messages are payloads the app processes entirely in code. FCM notification messages are handled by the OS and displayed automatically — bypassing your app's logic.

Why it matters for a notification library:

  1. Notification messages skip onMessageReceived() — your library never runs
  2. No inbox write, no channel routing, no suppression logic, no analytics
  3. No grouping, no deep link handling, no preference check
  4. The library has zero control over how the notification looks or behaves

Use data messages always. They arrive in onMessageReceived() regardless of app state — foreground, background, or killed — giving the library full control over every step.


Why do notifications stop working when my Android app is killed?

Push notifications from FCM continue working when the app is killed — but only if you use data messages, not notification messages.

Here is why:

  1. FCM maintains a persistent connection via Google Play Services — a separate, always-on process independent of your app
  2. When a data message arrives, the OS wakes your app's FirebaseMessagingService in the background
  3. onMessageReceived() runs briefly to process the message and post the notification
  4. Local scheduled notifications (AlarmManager) do stop after a reboot — because the alarm registry is cleared. A boot receiver (BOOT_COMPLETED) re-registers them from Room on startup

If push notifications stop after an app kill, the usual cause is using notification messages instead of data messages, or missing BOOT_COMPLETED registration for local alarms.


How do you handle the POST_NOTIFICATIONS permission on Android 13+?

POST_NOTIFICATIONS is a runtime permission introduced in Android 13 (API 33). Without it, NotificationManagerCompat.notify() silently does nothing — no error, no crash, just dropped notifications.

How a notification library handles it correctly:

  1. Declare in the library manifest — apps using the library get the permission declaration automatically via manifest merger
  2. Check before every notify() call — use NotificationManagerCompat.areNotificationsEnabled() as a dual check (covers pre-13 and post-13 devices)
  3. Never request the permission from the library — the rationale dialog must come from the host app, where it fits the UX context
  4. Expose a helper — provide NotificationPermissionManager.shouldRequest() and .requestPermission(activity) that the host app calls at the right moment
  5. Cache the permission state at init() time — avoids redundant system calls on every notification

On Android 12 and below, notifications are granted by default — the library's check resolves immediately without a dialog.


Why are Android notification channels permanent after first registration?

Notification channel importance (silent, low, default, high) cannot be changed programmatically once a channel has been shown to the user. Android hands control to the user permanently at that point.

This has two direct consequences for library design:

  1. Get channel importance right at init() time — a mistake means users must uninstall and reinstall to reset the channel, or you must create a new channel ID
  2. Never recreate a channel to change its importance — creating messages_v2 just to get a different importance level is a known anti-pattern that fragments the user's notification settings

The correct approach: define channel importance in a sealed ChannelType enum inside the library. Review it carefully before shipping. Treat it as an immutable contract, not a configurable parameter.


What is goAsync() and why does every BroadcastReceiver in a notification library need it?

goAsync() returns a PendingResult token that tells the OS: "this receiver is not done yet — don't kill the process."

Without it:

  1. onReceive() has approximately 10 seconds before the OS considers it complete
  2. Coroutine work launched inside onReceive() runs on a background thread the OS doesn't know about
  3. The OS sees onReceive() return and terminates the process — killing the coroutine mid-work
  4. Inbox writes, analytics events, and alarm re-registrations are silently dropped

With goAsync():

  1. The receiver holds the PendingResult token
  2. Coroutines run safely to completion
  3. The receiver calls pendingResult.finish() when done — releasing the OS hold
  4. The process lives long enough to complete every side effect

A notification library has three BroadcastReceivers — alarm delivery, dismissal tracking, and action handling — and all three need goAsync() for the same reason.


How do you suppress a notification when the user is already on that screen?

The ordered broadcast pattern lets active UI components intercept and cancel a notification before it reaches the system tray.

How it works:

  1. The NotificationDispatcher sends an ordered broadcast with the notification payload — not a direct notify() call
  2. UI components (Activities, Fragments) register a high-priority BroadcastReceiver while they are visible
  3. If the matching screen is in the foreground, its receiver calls abortBroadcast() — the broadcast stops, notify() is never called
  4. If no screen is active, the broadcast reaches the library's default receiver, which calls notify() normally

The alternative — a CurrentScreenTracker singleton — works but tightly couples the library to the host app's navigation stack. The ordered broadcast approach lets UI components opt in independently, which is the better pattern for a reusable library.


Deep linking from a notification requires a PendingIntent that opens the correct destination when the notification is tapped.

The recommended approach for a notification library:

  1. Every notification is built with a deepLink URI — e.g., app://orders/123
  2. The library wraps this in an Intent targeting a transparent NotificationTapActivity declared in the library's manifest
  3. NotificationTapActivity.onCreate() runs before any layout renders:
    • Logs the OPENED analytics event
    • Marks the inbox record as read
    • Cancels the OS notification badge
    • Starts an Intent for the real deep link destination
  4. The user sees only the final destination — the transparent activity is invisible

Why route through an intermediate activity? Direct deep links bypass analytics and inbox state updates. The intermediate activity guarantees those steps run regardless of how the deep link is resolved or which nav framework the host app uses.


What is the difference between AlarmManager and WorkManager for scheduling local notifications?

AlarmManagerWorkManager
Timing precisionExact (to the millisecond)Inexact (OS batches to save battery)
Battery costHigher — wakes device CPULower — runs during natural wake windows
Doze behaviourBlocked during Doze (use setExactAndAllowWhileIdle)Deferred until Doze exits
Best forReminders, alarms, time-critical eventsPeriodic sync, deferrable cleanup
Survives rebootNo — must re-register via BOOT_COMPLETEDYes — WorkManager persists the job

How a notification library handles both:

Expose a Schedule abstraction. If the consumer marks a notification as exact = true, the library uses AlarmManager.setExactAndAllowWhileIdle(). Otherwise it uses WorkManager. The consumer never writes an AlarmManager call directly.

For AlarmManager jobs: the library stores scheduled notifications in Room and re-registers them via a BOOT_COMPLETED receiver on device restart.


How do you design an in-app notification inbox like Instagram's?

An in-app notification inbox is not an OS notification at all — it is a database-backed list of records displayed inside the app.

The components:

  1. Room tablenotifications with fields: id, title, body, deepLink, channel, isRead, receivedAt
  2. Write path — every notification (push and local) writes to Room before calling notify(). If the OS notification is suppressed or missed, the inbox record still exists
  3. Unread count — a Room @Query returning Flow<Int> counting isRead = false records. The UI badge observes this Flow — no polling needed
  4. Mark as read — triggered either by tapping the notification (via NotificationTapActivity) or by opening the inbox screen
  5. Eviction — a low-priority periodic WorkManager task deletes records older than the configured retention window (e.g., 30 days)

The inbox write happening before the OS notify() call is the critical ordering. It ensures a record exists even if the notification is suppressed, the app is in the foreground, or the user dismisses the system-tray notification without tapping it.


Which companies ask the Android notification system design question?

Meta, Google, Snap, Uber, Airbnb, and Shopify all include variants of this question in senior Android engineer interviews.

It is popular for three reasons:

  1. Platform depth is required — candidates cannot bluff through FCM internals, BroadcastReceiver lifecycle, and Android 13 permission changes
  2. Library design reveals seniority — a junior answer describes Android APIs; a senior answer wraps them behind a clean abstraction that hides platform complexity from callers
  3. It scales well — interviewers can drill into any layer (FCM pipeline, channel strategy, analytics, preference sync) and reveal how deep the candidate's knowledge actually goes

Red flags interviewers watch for:

  1. Using notification messages instead of data messages ("it's simpler for the server team")
  2. Not mentioning POST_NOTIFICATIONS silent failure on Android 13
  3. Proposing notify() directly without wrapping NotificationManagerCompat
  4. Missing goAsync() in any BroadcastReceiver
  5. Not syncing preference opt-outs back to the server

If you want to practice walking through a design like this out loud — with real interruptions, follow-up questions, and the pressure of time — Mockingly.ai has Android-focused system design simulations built for senior engineers preparing for interviews at Meta, Google, Snap, and beyond.

Companies That Ask This

Ready to Practice?

You've read the guide — now put your knowledge to the test. Our AI interviewer will challenge you with follow-up questions and give you real-time feedback on your system design.

Free tier includes unlimited practice with AI feedback • No credit card required

Related System Design Guides