Implementing Silent Push Notifications in Mobile Apps
Silent push — a notification without sound, without banner, without user interaction. It arrives in the background, wakes the app, gives it several seconds to execute code. Used for background content sync, cache invalidation, updating badge counter without opening the app.
iOS: Background Fetch Through Silent Push
On iOS, silent push requires two things: the content-available: 1 flag in payload and enabled Background Mode "Remote notifications" in Xcode Capabilities.
APNs Payload:
{
"aps": {
"content-available": 1
},
"sync_type": "messages",
"last_known_id": "msg_8823"
}
No alert, no sound — pure background call. iOS will invoke:
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
guard let syncType = userInfo["sync_type"] as? String else {
completionHandler(.noData)
return
}
Task {
do {
let hasNewData = try await SyncManager.shared.sync(type: syncType)
completionHandler(hasNewData ? .newData : .noData)
} catch {
completionHandler(.failed)
}
}
}
Critical point: iOS gives about 30 seconds to execute. If completionHandler is not called in that time — iOS forcefully terminates the background task. Also, iOS doesn't guarantee silent push delivery at low battery (Low Power Mode) or when user force-quit the app.
Force quit is the main pain. Force quit through iOS task switcher completely blocks silent push until the next manual app open. This is documented behavior, can't be bypassed.
Android: FCM Data Message and WorkManager
Android has no direct equivalent of "silent push" — there's FCM Data Message, which always lands in FirebaseMessagingService.onMessageReceived regardless of app state (provided the app isn't killed by system Doze).
class AppFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val syncType = message.data["sync_type"] ?: return
// Run WorkManager task — short, with execution guarantee
val workRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf("sync_type" to syncType))
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(applicationContext).enqueue(workRequest)
}
}
setExpedited() — request for immediate execution. Android 12+ requires setForegroundAsync inside expedited worker or foreground service, otherwise ANR on long operation.
Doze Mode — Android restricts background activity on unplugged devices. FCM uses high-priority messages to bypass Doze, but you need to explicitly set priority: "high" when sending through FCM:
{
"message": {
"token": "device_fcm_token",
"android": {
"priority": "HIGH"
},
"data": {
"sync_type": "messages",
"payload": "{...}"
}
}
}
Usage for Badge Counter
A popular use case — update the icon number without showing a notification:
// iOS — through silent push
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
if let badge = userInfo["badge"] as? Int {
UNUserNotificationCenter.current().setBadgeCount(badge) { _ in }
}
completionHandler(.newData)
}
On Android badge is updated through ShortcutBadger (third-party library) or through NotificationManagerCompat with setNumber(). There's no unified API — each launcher (Samsung, Xiaomi, Huawei) has its own mechanism.
Limitations and Quota
iOS 13+ introduced BGTaskScheduler and background processing quota. If the app too frequently requests background execution and doesn't provide user benefit (according to iOS) — the system starts throttling calls. Returning .noData from completionHandler in response to silent push where there's no data — important for correct quota system operation.
Timeframe
Setting up silent push for iOS (Background Modes, handler) and Android (FCM Data Message + WorkManager), covering edge cases (Doze, force quit, quota) — 3–5 working days.







