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.
- 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.
- The server opens a few long-lived HTTP/2 connections per vendor. APNs:
POST /3/device/<token>onapi.push.apple.com:443, headersapns-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:sendwith an OAuth access token andandroid.ttl,android.collapse_key,android.priorityin the JSON. - 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). - The vendor answers per request. Act on it:
| result | meaning | action |
|---|---|---|
| 200 | accepted by the vendor | done; not a delivery receipt |
APNs 410 + timestamp, FCM 404 | token dead | delete token (unless re-registered after timestamp) |
APNs 403 ExpiredProviderToken | JWT too old | new JWT, resend |
| 400, 401, 403, 413 | bad token format, credentials, payload | fix and alert; don't retry |
| 429 | throttled | FCM: honour retry-after, else 60 s; APNs: back off |
| 5xx | vendor trouble | exponential backoff with jitter, floor 10 s, drop after the TTL |
- 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_STREAMSinstead 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-idto 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_seenand 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
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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| firebase-admin (Node) | TypeScript | firebase/firebase-admin-node | npm install firebase-admin | — | — | — | — | FCM v1 send; built-in retry; also reaches iOS via FCM |
| firebase-admin (Go) | Go | firebase/firebase-admin-go | go get firebase.google.com/go/v4@latest | — | — | — | — | FCM send, topics, error helpers |
| Pushy | Java | jchambers/pushy | Maven com.eatthepath:pushy | — | — | — | — | APNs HTTP/2 client with connection pooling and token auth |
| apns2 | Go | sideshow/apns2 | go get -u github.com/sideshow/apns2 | — | — | — | — | APNs HTTP/2 client, token and certificate auth |
| gorush | Go | appleboy/gorush | docker 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