Mobile Computing · Background Execution

Push as a Wakeup Path from APNs to the App Execution Window

How a silent notification becomes a bounded execution window, why the push service collapses and throttles deliveries, and what the app can still be trusted to do.

Polling is the obvious way for a server to reach an app that is not running, and it is the wrong one. A poll requires the app to be granted execution before it can ask, so the app must schedule a wakeup of its own, and that wakeup costs the same whether or not there is anything to fetch. A push inverts the dependency: the server asks the network to deliver a message, the network asks the device, and the device decides whether the message justifies starting the app. The cost is paid once per actual event rather than once per interval.

The inversion is what makes push the cheapest wakeup path available on a phone, and also the most heavily governed. Every layer between the server and the app’s first instruction has its own policy about how often it will cooperate.

The delivery chain

A push traverses four parties, each of which can hold, collapse, or drop it.

The application server signs a request and sends it to a push service over an authenticated connection — HTTP/2 with a provider certificate on Apple’s service, an OAuth-credentialled HTTP request on Firebase’s. The push service authenticates the sender, checks that the device token or registration token is still valid, and queues the message.

The push service then delivers over a long-lived connection that the device keeps open to it. On Apple’s platforms this connection is maintained by a system daemon rather than by any app, which is why an app that is not running can still receive a notification: the daemon holds the socket, the radio wakes for the delivery, and the daemon decides what to do with the payload. Android’s Firebase path works the same way through Google Play services.

Only then does the app process matter. If the payload is a user-visible alert, the system can render it without launching the app at all. If the payload asks for silent delivery, the system may launch the app, or may not.

That last sentence carries the whole design constraint of this article. A silent push is a request for a wakeup, not a delivery of one, and the decision belongs to the device.

Silent delivery and the constraints on it

Apple’s silent notification is a payload with the content-available key set and no alert, sound, or badge. When the system accepts it, the app is launched into the background and receives a callback on the application delegate; the app then has a bounded window in which to work, and that window is not a documented duration. When the system declines, the app is not launched, and nothing reports the decline. The server’s send succeeded. The device received the bytes. The app simply never ran.

The constraints on acceptance are cumulative. The device must have background refresh enabled for the app, must not be in a state the system considers hostile to background work — low power mode is the documented example — and must not have decided that this app’s silent pushes are being used to excess. Apple’s guidance states a rate limit without publishing the number, and the enforcement is per device and per app.

The practical consequence is that a silent push is a hint about state, not a command to fetch. A design that sends one silent push per message and expects one fetch per push degrades in a specific way: as the volume rises, the acceptance rate falls, and the fetches that do happen arrive later and later until they are batched with other work. The system is doing the coalescing described in Timer Coalescing and the Deferrable Work Budget, but with a policy the app cannot see.

Collapse keys and why the payload should be small

Both major push services support a mechanism for replacing an undelivered message with a newer one for the same logical subject. Apple calls it apns-collapse-id, a header value that the service uses to identify messages that should be merged; Firebase has an equivalent collapse key. Messages with the same identifier that have not yet been delivered are collapsed into one, so a device that has been offline for an hour receives the latest state rather than sixty stale ones.

Collapsing is only correct if the payload carries state rather than an event. A payload that says a new message exists is an event, and collapsing sixty of them into one loses fifty-nine events. A payload that says “the unread count is 61” is state, and collapsing is exactly right — the device ends up with the current count and no stale intermediate values.

The size ceiling pushes in the same direction. A push payload is limited to a few kilobytes — Apple’s documented limit is 4 KB for the notification payload — which is enough for a compact state description and not enough for a document. Anything larger must be fetched, which means the push must carry enough information to tell the app what to fetch and, ideally, enough to render something useful if the fetch never happens.

{
  "aps": {
    "content-available": 1,
    "apns-collapse-id": "inbox-42",
    "thread-id": "inbox"
  },
  "sync": {
    "cursor": "1750000000:8842",
    "unread": 61,
    "reason": "message-arrived"
  }
}

The cursor is the load-bearing field. It makes the subsequent fetch incremental: the app asks for changes after that position rather than for the whole mailbox, which keeps the fetch inside the execution window and makes a repeated fetch harmless.

Priority is a separate axis from silence

A push carries a priority that tells the network how urgently to schedule it. Apple’s service accepts a priority of 10 for immediate delivery and 5 for a delivery that may be deferred to save power; a notification with content-available and no alert is expected to use the lower value, and sending silent pushes at the higher one is a documented cause of throttling. Firebase exposes the same distinction as high and normal priority, with high priority reserved for messages whose delivery the user is actively waiting for.

The two axes are independent, and confusing them is common. Priority describes how the network treats the bytes. Silence describes whether the user is told anything. A high-priority alert is a message the user must see immediately. A normal-priority silent push is a hint that may be delivered whenever the device next wakes for other reasons. Sending a silent push at high priority buys latency at the cost of the app’s standing with the service, and the cost is paid later, when the pushes that do matter are the ones being throttled.

What the app should do in the window

The execution window after a silent push is short and unbounded in the sense that its length is not promised. Work inside it should be sized for the worst case and structured so that an interrupted run leaves a consistent record.

The shape that survives is: read the hint, update local state from it, enqueue any network work as a durable row, and return. The fetch itself then runs under the deferred-work machinery described in Durable Deferred Work and the Persistence Boundary, which gives it retries and a record that outlives the window. Doing the fetch inline means a window that expires mid-request leaves nothing behind, and the next push starts the same fetch from the beginning.

The reverse ordering — fetch first, then update — is worse, because a fetch that fails leaves the local state unchanged and the push’s information discarded. Applying the hint first means the app is correct even if the fetch never completes.

A second rule concerns what must not be done. Long-running uploads, image processing, and database migrations do not belong in a silent-push window. They belong in a task that the system is willing to run to completion, which is the distinction covered in Declaring Long-Running Tasks Without Being Killed.

Where the chain drops a message

Tokens expire. A device token can be invalidated by a reinstall, a restore to a new device, or a service-side rotation, and the push service reports the invalidation only to the sender, on a subsequent send. A server that keeps a token forever accumulates failures and, worse, may treat the silence as successful delivery. The token must be treated as a cache entry with an invalidation path, refreshed on every app launch and on every registration callback.

Delivery is not ordering. Two pushes sent in sequence can arrive in either order, and a push can arrive twice. Anything that applies a cursor must therefore be idempotent in the face of a repeated or out-of-order position, which means comparing the incoming cursor with the stored one rather than assuming forward progress.

The connection itself is a battery consideration. The device’s push connection is shared with other apps on Apple’s platforms, so its maintenance cost is amortised, but the radio wakeup it triggers is not free. A burst of pushes delivered together is cheaper than the same pushes spread across an hour, which is another reason the service batches.

Finally, the user can turn it off. Background refresh and notification permissions are user settings, and the app cannot distinguish “the server sent nothing” from “the device declined to run me” from “the user disabled background refresh”. A design that depends on push for correctness needs a path that works on the next foreground launch, and that path needs to reconcile the full state rather than assume the pushes kept it current.

References

  1. Apple Developer Documentation — Pushing background updates to your app — Apple Inc.
  2. Firebase Cloud Messaging — Message priority — Google
  3. RFC 8030: Generic Event Delivery Using HTTP Push — IETF