| name | silent-push |
| description | Deliver background (silent) pushes to mobile reliably within OS budgets, and design fallbacks. Use when you need data-only pushes for sync or state invalidation. |
Silent Push
Instructions
Silent (a.k.a. background, data-only) pushes wake the app briefly to refresh state without showing UI. Both iOS and Android rate-limit them aggressively; treat them as a hint, never a delivery guarantee.
1. When to Use
Good fits:
- Invalidate a local cache after a server-side change.
- Trigger a short sync so the next app open is instant.
- Badge count updates that are not time-critical.
Poor fits:
- Mission-critical state (payments, chat messages you must deliver) -- always pair with a user-visible push or server-side authoritative pull.
- Frequent polling replacement -- OS budgets will silently drop most sends.
2. iOS (APNs)
Set content-available: 1, no alert, apns-push-type: background, priority 5 (required; priority 10 background pushes are throttled).
{
"aps": { "content-available": 1 },
"type": "cache_invalidate",
"entity": "feed"
}
POST /3/device/<token>
apns-push-type: background
apns-priority: 5
apns-topic: com.example.app
iOS throttles background pushes per app and per device (approximately 2–3 per hour, highly dynamic based on Low Power Mode, battery state, and app usage). The app gets ~30 s in application(_:didReceiveRemoteNotification:fetchCompletionHandler:) to do work and call the completion handler.
3. Android (FCM)
Data-only messages (no notification key) are delivered to FirebaseMessagingService.onMessageReceived. Use priority: "normal" by default; high only for user-visible results.
{
"message": {
"token": "...",
"data": { "type": "cache_invalidate", "entity": "feed" },
"android": { "priority": "NORMAL" }
}
}
On Android, Doze and App Standby will defer normal-priority pushes. high priority bypasses Doze but burns battery and counts against the app's "push exemption" budget. Apps terminated by the user (swiped from recents on some OEMs) may not receive FCM at all.
4. Budgets
Plan as if you get a few silent pushes per user per day, distributed unevenly. If you need more throughput, switch to user-visible notifications for the highest-priority events and pull-based sync for the rest.
5. Reliability Tradeoffs
Silent push is best-effort. Pair it with a pull:
Silent push (hint) -> app fast-paths next update
-> app also pulls on resume / periodic background refresh
Server remains authoritative; delivery loss just means slower freshness.
Never increment an authoritative counter on the client in response to a silent push. Use them to invalidate, not to update.
6. Payload Design
Keep payloads small (< 1 KB) and deterministic:
{ "type": "invalidate", "entity": "orders", "id": "ord_01HX7..." }
The app looks up entity in a dispatch table and triggers the appropriate background task. Version the payload with a v field so old clients ignore types they do not understand.
7. Dedup and Ordering
Pushes can arrive out of order, duplicated, or not at all. Include a monotonically increasing seq per entity; drop older ones:
val last = prefs.getLong("inv.$entity.$id", 0)
if (data["seq"]!!.toLong() <= last) return
prefs.edit().putLong("inv.$entity.$id", data["seq"]!!.toLong()).apply()
invalidate(entity, id)
8. Send Side
Throttle aggressively per user to stay within provider/OS budgets:
async function sendSilent(userId: string, payload: SilentPayload) {
if (!(await budget.consume(userId, category = "silent"))) return;
const tokens = await tokens.forUser(userId);
for (const t of tokens) await queue.enqueue({ token: t, payload, priority: "normal" });
}
Deduplicate identical invalidations within a short window (e.g., 10 s) -- many server events often collapse into one cache-bust.
9. Observability
- Log intent vs dispatch vs provider-ack vs app-ack (via a lightweight reply ping if it matters).
- Track the gap: how often the app's next pull happened before the silent push arrived. High gap -> silent push is not helping; reconsider.
Checklist