Home › Networking › Messaging & email protocols
algorithm medium

Mobile push delivery

Problem App makers need to alert users who have closed the app, when the phone vendor's servers are the only route to the device, without wasting quota or waking phones with stale messages.

Sending notifications through APNs and FCM: HTTP/2 requests, token hygiene, collapse keys, TTL and priority, and the retry rules that keep you un-throttled.

O(1) per message with a (token, collapse) map; 1 HTTP/2 request per device; payload at most 4096 bytes
push-notificationsapnsfcmhttp2mobile
Overview

You cannot open a socket to a sleeping phone: each OS keeps one shared connection to its vendor (Apple APNs, Google FCM), and you hand your message to that vendor over HTTP/2. The vendor stores few messages per device, delivers when it is online, and reports only the hand-off, not the arrival. Good senders therefore manage four things: device tokens that die, collapse keys that merge, TTL and priority that bound urgency, and retries that don't amplify outages.

Key facts

Use when

  • Re-engaging users whose app is closed: chat, delivery status, alerts.

Avoid when

  • The app is open: use WebSockets.
  • You need guaranteed delivery or an arrival receipt: push is best effort.
  • Server-to-server callbacks: see webhook delivery.

Deep dive

How it works

Parts:

  • token registry — device tokens per user with their upload time; a token is deleted on APNs 410 or FCM 404.
  • outbox — messages with payload (≤ 4096 B), TTL, priority and collapse key, waiting to be sent.
  • vendor connections — a few long-lived HTTP/2 connections per vendor (APNs, FCM); one request per device, O(1) per message.
  • credential signer — ES256 JWT for APNs, OAuth access token for FCM, both short-lived.
  • result handler — turns each vendor answer (an accept, not a delivery receipt) into done, delete token, re-auth, back off or alert.
  1. The app asks the OS for a token (APNs device token, FCM registration token) and uploads it with a timestamp, again on every launch since tokens change.
  2. The server opens a few long-lived HTTP/2 connections per vendor. APNs: POST /3/device/<token> on api.push.apple.com:443, headers apns-topic (bundle ID), apns-push-type, apns-priority (10 now, 5 power-aware, 1 low), apns-expiration (unix seconds; 0 = try once, don't store), apns-collapse-id (at most 64 bytes), authorization: bearer <JWT>. FCM: POST /v1/projects/<id>/messages:send with an OAuth access token and android.ttl, android.collapse_key, android.priority in the JSON.
  3. APNs auth is an ES256 JWT (key ID + team ID, iat); refresh it no more than every 20 and no later than every 60 minutes. Sending a new token more often is an error (TooManyProviderTokenUpdates).
  4. The vendor answers per request. Act on it:
resultmeaningaction
200accepted by the vendordone; not a delivery receipt
APNs 410 + timestamp, FCM 404token deaddelete token (unless re-registered after timestamp)
APNs 403 ExpiredProviderTokenJWT too oldnew JWT, resend
400, 401, 403, 413bad token format, credentials, payloadfix and alert; don't retry
429throttledFCM: honour retry-after, else 60 s; APNs: back off
5xxvendor troubleexponential backoff with jitter, floor 10 s, drop after the TTL
  1. On the device, high priority delivers at once; normal priority follows the power state and may be batched.
app ─token─► token registry ◄── delete on 410/404 ──┐
event ─► outbox ─► sender ─HTTP/2 + JWT/OAuth─► APNs/FCM ─► device
                     └──── result handler: retry, back off, alert ┘

Code

Vendor-neutral core: outbox with collapsing, priority and TTL; response classifier; backoff; token lifecycle. The HTTP/2 client is your library of choice.

use std::collections::HashMap;

#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Platform { Apns, Fcm }
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum Priority { Normal, High } // APNs 5/10, FCM normal/high

pub const MAX_PAYLOAD: usize = 4096; // APNs and FCM both: 4 KB

#[derive(Clone, Debug)]
pub struct Push {
    pub token: String,
    pub collapse: Option<String>, // apns-collapse-id (<= 64 bytes) / FCM collapse_key
    pub priority: Priority,
    pub created: u64,             // unix seconds
    pub ttl: u64,                 // seconds; 0 = try once, never store, never retry
    pub payload: Vec<u8>,
    pub attempts: u32,
}
impl Push { fn expires(&self) -> u64 { self.created + self.ttl } }

#[derive(Debug, PartialEq)]
pub enum Verdict {
    Done,
    DropToken { since_ms: Option<u64> }, // APNs 410 carries "timestamp" (ms)
    Reauth,                              // refresh the JWT, resend at once
    Retry { after_s: u64 },
    Reject,                              // this message is bad: don't retry
}

fn xorshift(s: &mut u64) -> u64 { *s ^= *s << 13; *s ^= *s >> 7; *s ^= *s << 17; *s }

/// Exponential backoff, floor 10 s (FCM guidance), cap 15 min, half fixed + half random.
fn backoff(attempt: u32, rng: &mut u64) -> u64 {
    let e = (10u64 << attempt.min(7)).min(900);
    e / 2 + xorshift(rng) % (e / 2 + 1)
}

pub fn classify(p: Platform, status: u16, reason: &str, retry_after: Option<u64>,
                timestamp_ms: Option<u64>, attempt: u32, rng: &mut u64) -> Verdict {
    match (p, status, reason) {
        (_, 200, _) => Verdict::Done,
        (Platform::Apns, 410, _) => Verdict::DropToken { since_ms: timestamp_ms },
        (Platform::Apns, 403, "ExpiredProviderToken") => Verdict::Reauth,
        (Platform::Fcm, 404, _) => Verdict::DropToken { since_ms: None }, // UNREGISTERED
        (Platform::Fcm, 429, _) => Verdict::Retry { after_s: retry_after.unwrap_or(60) },
        (_, 429, _) | (_, 500..=599, _) => Verdict::Retry { after_s: backoff(attempt, rng) },
        _ => Verdict::Reject, // 400/401/403/413 ...: fix the request or credentials
    }
}

/// Retry only while the message is still timely.
pub fn schedule(push: &Push, v: &Verdict, now: u64) -> Option<u64> {
    match v {
        Verdict::Retry { after_s } if push.ttl > 0 && now + after_s < push.expires() => Some(now + after_s),
        Verdict::Reauth if push.ttl > 0 && now < push.expires() => Some(now),
        _ => None,
    }
}

#[derive(Default)]
pub struct Outbox { queue: Vec<Push> }

impl Outbox {
    pub fn enqueue(&mut self, p: Push) -> Result<(), &'static str> {
        if p.payload.len() > MAX_PAYLOAD { return Err("payload too large"); }
        if let Some(c) = &p.collapse {
            // newer message replaces the queued one: one send instead of two
            if let Some(old) = self.queue.iter_mut().find(|q| q.token == p.token && q.collapse.as_ref() == Some(c)) {
                *old = p;
                return Ok(());
            }
        }
        self.queue.push(p);
        Ok(())
    }
    /// Up to `max` live messages, high priority first, oldest first; expired ones are dropped unsent.
    pub fn take(&mut self, now: u64, max: usize) -> Vec<Push> {
        self.queue.retain(|p| now < p.expires() || (p.ttl == 0 && p.attempts == 0));
        self.queue.sort_by_key(|p| (std::cmp::Reverse(p.priority), p.created));
        let n = max.min(self.queue.len());
        self.queue.drain(..n).map(|mut p| { p.attempts += 1; p }).collect()
    }
}

#[derive(Default)]
pub struct Tokens { registered_at: HashMap<String, u64> } // unix seconds

impl Tokens {
    pub fn register(&mut self, token: &str, now: u64) { self.registered_at.insert(token.to_string(), now); }
    /// Delete on 410 only if the app has not re-registered the same token after APNs saw it die.
    pub fn drop_if_stale(&mut self, token: &str, since_ms: Option<u64>) -> bool {
        match (self.registered_at.get(token), since_ms) {
            (Some(&r), Some(ms)) if r * 1000 > ms => false,
            (Some(_), _) => { self.registered_at.remove(token); true }
            _ => false,
        }
    }
}

Compiled and tested: collapse, priority order, expiry, 4096-byte cap, TTL-bounded retry, re-registration rule.

Complexity and constants

  • CPU is negligible; the cost is requests (one per device message), connections and bytes. A request is the payload (at most 4 KB) plus HTTP/2 headers; the JWT rides on every APNs request, so header bytes matter at high rates (Apple gives HPACK encoding advice for them).
  • Number of in-flight streams is set by the vendor and varies with load; read SETTINGS_MAX_CONCURRENT_STREAMS instead of assuming one (APNs allows a single stream until the first request with a valid token succeeds).
  • FCM quota: a token bucket per project and minute (600k default); a burst at second 0 of every minute wastes the bucket and starves you at second 50.
  • Fan-out to millions: throughput is connections x streams / RTT; measure your own.

Efficiency

  • Cost: CPU is negligible. One message is one HTTP/2 request: payload (≤ 4 KB) plus headers, with the JWT repeated on every APNs request. The bound is the vendor quota (FCM default 600k messages/minute per project) and connections × streams / RTT; on the phone, each high-priority delivery wakes the radio.
  • Levers:
    • long-lived connections — skips TLS and HTTP/2 setup per batch (Apple, APNs connection guidance) — always; ping after an hour idle
    • collapse key + short TTL — many updates become one delivery per device (FCM collapsible messages) — state-like updates such as scores
    • several connections, fresh DNS lookup each — spreads load, a failure loses only a slice (Apple) — high fan-out
    • jittered, ramped sending — no wasted quota at minute boundaries (FCM scale guide) — campaigns at the top of the hour
    • prune tokens on APNs 410 / FCM 404 — no requests to dead devices (Apple, FCM token docs) — always
  • Naive → standard → state of the art:
  • New connection and fresh JWT per message, every update sent · order of 3 round trips plus one request per update
  • Pooled connections, JWT cached 20-60 min, collapse + TTL, dead tokens pruned · one request per state change
  • Sharded by token over a few warm connections, ramped and jittered; open socket for online users, push only after the session closes · requests only where no socket exists

Implementation notes

  • Shard by token hash so one device's messages share a connection; APNs may still reorder, so add a sequence number to the payload.
  • Persist the outbox (created, ttl) before acknowledging your caller; send rejects to a dead-letter queue.
  • Read the response body (reason, timestamp); status alone cannot separate a bad token from a wrong environment.
  • Set apns-id to your own UUID and log it; APNs echoes it in errors.
  • On HTTP/2 GOAWAY, reconnect and re-queue streams above last_stream_id.
  • Key the token table by (app, token); keep last_seen and a sandbox/production flag.

Pitfalls

  • Treating 200 as "delivered". It is "accepted".
  • Deleting a token on BadDeviceToken: it also means sandbox/production mismatch.
  • Retrying 4xx in a loop: APNs disconnects providers with too many error responses.
  • Unbounded retries: a message past its TTL is worse than none; FCM suggests giving up after about 60 minutes of failure.
  • Regenerating the JWT per request.
  • Backoff without jitter: workers retry together and recreate the overload.
  • More than four FCM collapse keys per device: extras are dropped arbitrarily.
  • Private data in payloads: vendors see the content; send an id and let the app fetch it.
  • Expecting background pushes to arrive reliably: they are throttled.

Variants and alternatives

FCM topicsone request reaches many devices, at lower payload limits (2048 bytes) and weaker guarantees.
MQTT or WebSocket from the appown the connection (no Google services, desktop); costs battery.
Web Push (RFC 8030, VAPID RFC 8292)the browser analogue with the same TTL/urgency/topic ideas.
SMSreaches phones without your app, costs money per message.

In production

Every mobile messaging stack sits behind APNs and FCM; hosted senders (OneSignal, Braze, Airship, Amazon SNS mobile push) wrap these two vendor APIs.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
firebase-admin (Node)TypeScriptfirebase/firebase-admin-nodenpm install firebase-admin————FCM v1 send; built-in retry; also reaches iOS via FCM
firebase-admin (Go)Gofirebase/firebase-admin-gogo get firebase.google.com/go/v4@latest————FCM send, topics, error helpers
PushyJavajchambers/pushyMaven com.eatthepath:pushy————APNs HTTP/2 client with connection pooling and token auth
apns2Gosideshow/apns2go get -u github.com/sideshow/apns2————APNs HTTP/2 client, token and certificate auth
gorushGoappleboy/gorushdocker pull appleboy/gorush————Push server for APNs and FCM behind one API

Sources

  • Apple, Sending notification requests to APNs. docs
  • Apple, Handling notification responses from APNs. docs
  • Apple, Establishing a token-based connection to APNs. docs
  • Google, Optimize and scale message delivery (Firebase). docs
  • Google, Throttling and quotas (FCM). docs
  • Google, Manage FCM registration tokens. docs
  • Google, Collapsible message types; Setting message lifespan; Message priority on Android (FCM docs). collapse · lifespan · priority
  • Google, FCM error codes. docs
  • B. Thomson, E. Damaggio, B. Raymor, RFC 8030: Generic Event Delivery Using HTTP Push, 2016. rfc
  • M. Thomson, P. Beverloo, RFC 8292: VAPID for Web Push, 2017. rfc

Builds on

Used by

Systems (30):

Alternatives and related