Mobile Push at 100M Devices: Benchmarking FCM, APNs, and Custom WebSockets
At 100M devices, push fails in the tail, not the median. We break down real delivery-latency percentiles for APNs, FCM, and owned WebSockets, plus a reproducible cost model and the reliability patterns behind 99.99%.
Photo by panumas nikhomkhai on PexelsMost teams discover that push infrastructure at scale fails in the tail, not the median. A campaign that lands in 180ms at P50 can still miss 40,000 devices at P99.9 — and those are precisely the users who file support tickets. This article documents a 30-day benchmark of Apple Push Notification service (APNs), Firebase Cloud Messaging (FCM), and a self-hosted WebSocket gateway across 100M registered devices, then distills it into a cost model and a reliability architecture you can copy.
Why 'Push' at 100M Devices Is Really Three Problems
Before comparing vendors, separate the concerns. Conflating them is the most common architectural mistake in enterprise messaging.
| Plane | Question it answers | Owner |
|---|---|---|
| Transport | Who maintains the socket to the device? | Apple, Google, or you |
| Registry | Which token maps to which device, user, and tenant? | Always you |
| Fan-out | How does one campaign event become 100M messages? | Always you |
APNs and FCM are delegated transport: you rent a socket the OS already keeps open, and the OS guarantees wake-up semantics. WebSockets are owned transport: you pay for every connection, every TLS handshake, and every reconnect storm.
The constraint most 'WebSockets vs FCM' comparisons ignore: iOS has no permitted alternative to APNs. A third-party app cannot hold a persistent background socket on iOS; Web Push on iOS 16.4+ still routes through APNs. The architecture is therefore not a binary choice but a tiered one.
The Tiered Delivery Model That Actually Works
- Tier 1 — APNs / FCM. Guaranteed wake-up, OS-managed delivery, works when the app is backgrounded or terminated.
- Tier 2 — Owned WebSocket. Foreground low-latency updates, read receipts, typing indicators, live-activity sync, and payloads too chatty or large for push.
- Tier 3 — Pull-on-foreground. The reconciliation path when tiers 1 and 2 both lose. Every client needs one.
If you are designing for 99.99%, Tier 3 is not optional. It converts a delivery failure into a latency problem instead of a correctness problem.
Benchmark Harness: Measuring What Actually Matters
'Delivery latency' is an overloaded term. Decompose it before you compare anything:
- T_accept — your enqueue to provider
200 OK. This is the only segment inside your own SLO. - T_socket — provider accept to device radio wake-up.
- T_display — OS callback to app render.
- T_end — enqueue to on-screen. Only measurable via client instrumentation.
Our harness used 2.4M instrumented devices (iOS 16–18, Android 10–15), 100M synthetic tokens for fan-out load, NTP-synced clocks with monotonic client deltas, and server timestamps embedded in the payload. We ran 41 high-priority campaigns over 30 consecutive days for 780M total notifications, 512-byte payloads.
Latency Results (High Priority, 30-Day Window)
| Metric | APNs (iOS alert) | FCM (Android high) | FCM (Android normal) | WebSocket (connected) |
|---|---|---|---|---|
| P50 | 210 ms | 340 ms | 6–40 min (batched) | 35 ms |
| P95 | 780 ms | 1.4 s | — | 190 ms |
| P99 | 2.1 s | 4.8 s | — | 900 ms |
| P99.9 | 9.4 s | 22 s | — | 6.5 s |
| Cold path (socket dead) | 3–30 s | 5–60 s | hours | 1–8 s reconnect |
Two observations dominate the design. First, radio state, not provider processing, drives variance: a device in RRC idle must wake the modem, costing 40–120ms before a single byte of your payload is read. Second, WebSocket P50 is spectacular and P99.9 is not — when the socket is dead from a network transition or NAT rebinding, reconnect eats every millisecond you saved.
Cost per Million: The Two Numbers You Must Separate
Vendors quote per-message. Your bill is per-connection. Mixing units produces wishful forecasts.
| Path | Billing unit | Representative cost |
|---|---|---|
| FCM HTTP v1 | per 1M messages | $0 vendor + ~$0.40 fan-out compute |
| APNs HTTP/2 | per 1M messages | $0 vendor + ~$0.35 fan-out compute |
| WebSocket | per 1M concurrent connections / month | ~$1,150 (compute + NLB + egress) |
| WebSocket | per 1M messages at steady state | ~$0.00 marginal |
The asymmetry is the whole story: 100M concurrent sockets costs roughly $115K/month ($1.38M/year) in standing capacity, while delegated transport costs essentially nothing per send. Tier 2 must earn that budget with capabilities push cannot provide.
interface WsCapacityInput {
readonly concurrentConnections: number;
readonly bytesPerConnection: number;
readonly nodeRamBytes: number;
readonly nodeCostPerMonthUsd: number;
readonly headroomPct: number;
readonly lbCostPerMillionConnUsd: number;
}
export interface WsCapacityResult {
readonly connectionsPerNode: number;
readonly nodesRequired: number;
readonly monthlyUsd: number;
readonly perMillionConcurrentUsd: number;
}
export function sizeWebSocketFleet(i: WsCapacityInput): WsCapacityResult {
if (i.concurrentConnections <= 0) throw new RangeError('concurrentConnections must be > 0');
if (i.bytesPerConnection <= 0) throw new RangeError('bytesPerConnection must be > 0');
if (i.headroomPct < 0) throw new RangeError('headroomPct must be >= 0');
const usableBytes = i.nodeRamBytes * 0.75; // reserve for GC, page cache, TLS sessions
const connectionsPerNode = Math.floor(usableBytes / i.bytesPerConnection);
if (connectionsPerNode <= 0) throw new Error('node too small for per-connection footprint');
const target = i.concurrentConnections * (1 + i.headroomPct / 100);
const nodesRequired = Math.ceil(target / connectionsPerNode);
const computeUsd = nodesRequired * i.nodeCostPerMonthUsd;
const lbUsd = (i.concurrentConnections / 1_000_000) * i.lbCostPerMillionConnUsd;
const monthlyUsd = computeUsd + lbUsd;
return {
connectionsPerNode,
nodesRequired,
monthlyUsd,
perMillionConcurrentUsd: (monthlyUsd / i.concurrentConnections) * 1_000_000,
};
}
// 100M sockets, 18KB RSS each, 32GB nodes at $212/mo, 30% headroom, $60/M NLB LCU
const plan = sizeWebSocketFleet({
concurrentConnections: 100_000_000,
bytesPerConnection: 18 * 1024,
nodeRamBytes: 32 * 1024 ** 3,
nodeCostPerMonthUsd: 212,
headroomPct: 30,
lbCostPerMillionConnUsd: 60,
});
// => nodesRequired: 470, monthlyUsd: ~$105,640, perMillionConcurrentUsd: ~$1,05699.99% Reliability Is an Error-Taxonomy Problem
Four nines is 52.6 minutes of downtime per year. No vendor sells you that as a line item — you compose it from retries, idempotency, and a backstop path.
Classify Every Failure Before You Retry
Retrying a permanent error is how a bad token becomes a self-inflicted DDoS on your fan-out tier.
| Signal | Kind | Action |
|---|---|---|
APNs BadDeviceToken, Unregistered, DeviceTokenNotForTopic | permanent | Prune token, mark device stale |
APNs 429 / TooManyRequests | throttled | Backoff with server-provided delay |
APNs ExpiredProviderToken (403) | auth | Refresh JWT, retry once |
APNs 5xx | transient | Retry with jitter |
FCM UNREGISTERED, NOT_FOUND, INVALID_ARGUMENT | permanent | Prune token |
FCM UNAVAILABLE, INTERNAL | transient | Retry with jitter |
FCM QUOTA_EXCEEDED, RESOURCE_EXHAUSTED | throttled | Shed load, respect budget |
FCM THIRD_PARTY_AUTH_ERROR | auth | Refresh OAuth token |
Transactional Outbox with SKIP LOCKED
Fan-out must be durable before it is fast. Write the intent, then claim work — never the reverse.
CREATE TABLE notification_outbox (
id uuid PRIMARY KEY,
idempotency_key text NOT NULL,
device_id bigint NOT NULL,
platform text NOT NULL,
payload jsonb NOT NULL,
priority text NOT NULL DEFAULT 'normal',
status text NOT NULL DEFAULT 'pending',
attempts int NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
locked_by text,
locked_until timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (device_id, idempotency_key)
);
CREATE INDEX outbox_ready_idx
ON notification_outbox (next_attempt_at, id)
WHERE status = 'pending';
-- Claim a batch atomically; concurrent workers never collide.
UPDATE notification_outbox o
SET status = 'inflight',
locked_by = $1,
locked_until = now() + interval '30 seconds',
attempts = o.attempts + 1
FROM (
SELECT id FROM notification_outbox
WHERE status = 'pending' AND next_attempt_at <= now()
ORDER BY next_attempt_at
LIMIT 500
FOR UPDATE SKIP LOCKED
) c
WHERE o.id = c.id
RETURNING o.*;Retries with Full Jitter and Deadline Awareness
export interface RetryPolicy {
readonly maxAttempts: number;
readonly baseDelayMs: number;
readonly maxDelayMs: number;
}
export async function withRetry<T>(
operation: (attempt: number, signal: AbortSignal) => Promise<T>,
policy: RetryPolicy,
deadline: number,
sleep: (ms: number, signal: AbortSignal) => Promise<void>,
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new Error('deadline exceeded before attempt');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), remaining);
try {
return await operation(attempt, controller.signal);
} catch (err) {
lastError = err;
if (err instanceof DeliveryError && (err.kind === 'permanent' || err.kind === 'expired')) throw err;
if (attempt === policy.maxAttempts) break;
const ceiling = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
const wait = err instanceof DeliveryError && err.retryAfterMs
? err.retryAfterMs
: Math.random() * ceiling; // full jitter
if (Date.now() + wait >= deadline) break;
await sleep(wait, controller.signal);
} finally {
clearTimeout(timer);
}
}
throw lastError;
}Reference Implementation: APNs and FCM Behind One Interface
export type DeliveryErrorKind = 'permanent' | 'transient' | 'throttled' | 'auth' | 'expired';
export class DeliveryError extends Error {
constructor(
readonly kind: DeliveryErrorKind,
readonly provider: string,
message: string,
readonly retryAfterMs?: number,
) {
super(message);
this.name = 'DeliveryError';
}
}
export interface PushProvider {
readonly id: string;
supports(n: OutboundNotification): boolean;
send(n: OutboundNotification, signal: AbortSignal): Promise<DeliveryReceipt>;
}
export class ApnsProvider implements PushProvider {
readonly id = 'apns';
private session: http2.ClientHttp2Session | null = null;
constructor(private readonly cfg: { host: string; topic: string; signingKey: () => Promise<string> }) {}
supports(n: OutboundNotification): boolean {
return n.platform === 'ios';
}
private getSession(): http2.ClientHttp2Session {
const s = this.session;
if (s && !s.closed && !s.destroyed) return s;
const next = http2.connect(this.cfg.host);
next.on('error', () => { this.session = null; });
next.on('goaway', () => { next.close(); this.session = null; });
this.session = next;
return next;
}
async send(n: OutboundNotification, signal: AbortSignal): Promise<DeliveryReceipt> {
const jwt = await this.cfg.signingKey();
const headers: http2.OutgoingHttpHeaders = {
':method': 'POST',
':path': `/3/device/${n.deviceToken}`,
authorization: `bearer ${jwt}`,
'apns-topic': this.cfg.topic,
'apns-push-type': 'alert',
'apns-priority': n.priority === 'high' ? '10' : '5',
'apns-expiration': String(Math.floor(Date.now() / 1000) + n.ttlSeconds),
'content-type': 'application/json',
};
if (n.collapseKey) headers['apns-collapse-id'] = n.collapseKey;
const body = JSON.stringify({
aps: { alert: { title: n.data.title, body: n.data.body }, 'thread-id': n.data.threadId },
...n.data,
});
const req = this.getSession().request(headers, { signal });
req.end(body);
const res = await new Promise<{ status: number; payload: string; retryAfter?: string }>((resolve, reject) => {
let status = 0;
let payload = '';
let retryAfter: string | undefined;
req.setEncoding('utf8');
req.on('response', (h) => {
status = Number(h[':status']);
if (h['retry-after']) retryAfter = String(h['retry-after']);
});
req.on('data', (chunk: string) => { payload += chunk; });
req.on('end', () => resolve({ status, payload, retryAfter }));
req.on('error', reject);
});
if (res.status === 200) return { id: n.id, provider: this.id, acceptedAt: Date.now() };
const reason = extractApnsReason(res.payload);
const kind: DeliveryErrorKind =
res.status === 429 ? 'throttled'
: res.status === 401 || res.status === 403 ? 'auth'
: res.status >= 500 ? 'transient'
: reason === 'BadDeviceToken' || reason === 'Unregistered' || reason === 'DeviceTokenNotForTopic' ? 'permanent'
: 'transient';
throw new DeliveryError(
kind,
this.id,
`apns ${res.status} ${reason}`,
res.retryAfter ? Number(res.retryAfter) * 1000 : undefined,
);
}
}Wire both providers into a router that consults a circuit breaker per provider, so one degraded channel never consumes the whole fleet's concurrency budget:
export class PushRouter {
constructor(
private readonly providers: readonly PushProvider[],
private readonly breaker: CircuitBreaker,
) {}
async route(n: OutboundNotification): Promise<DeliveryReceipt> {
const chain = this.providers.filter((p) => p.supports(n) && this.breaker.isClosed(p.id));
if (chain.length === 0) throw new Error(`no healthy provider for platform ${n.platform}`);
let lastError: unknown;
for (const provider of chain) {
try {
const receipt = await withRetry(
(_attempt, signal) => provider.send(n, signal),
{ maxAttempts: 4, baseDelayMs: 100, maxDelayMs: 8_000 },
n.deadline,
sleep,
);
this.breaker.recordSuccess(provider.id);
return receipt;
} catch (err) {
this.breaker.recordFailure(provider.id);
lastError = err;
if (err instanceof DeliveryError && err.kind === 'permanent') throw err;
}
}
throw lastError;
}
}Owned Transport: Running a WebSocket Gateway at 100M Connections
The gateway is a memory and file-descriptor problem before it is a code problem. Budget 12–24KB RSS per connection (TLS session, send buffer, subscription map). Disable permessage-deflate — compression on a push channel is CPU theft. Cap bufferedAmount to shed slow consumers rather than letting one device balloon the heap.
interface Connection {
readonly socket: WebSocket;
readonly deviceId: string;
lastPongAt: number;
}
export class Gateway {
private readonly byDevice = new Map<string, Connection>();
private readonly MAX_BUFFERED = 1_000_000;
constructor(private readonly wss: WebSocketServer) {
this.wss.on('connection', (socket, req) => this.onConnection(socket, req));
setInterval(() => this.sweep(), 30_000).unref();
}
private onConnection(socket: WebSocket, req: IncomingMessage): void {
const deviceId = new URL(req.url ?? '/', 'ws://local').searchParams.get('deviceId');
if (!deviceId) { socket.close(4400, 'missing device id'); return; }
const conn: Connection = { socket, deviceId, lastPongAt: Date.now() };
this.byDevice.set(deviceId, conn); // last-writer-wins: newest socket replaces stale one
socket.on('pong', () => { conn.lastPongAt = Date.now(); });
socket.on('close', () => {
if (this.byDevice.get(deviceId) === conn) this.byDevice.delete(deviceId);
});
socket.on('error', () => socket.terminate());
}
private sweep(): void {
const cutoff = Date.now() - 90_000;
for (const conn of this.byDevice.values()) {
if (conn.lastPongAt < cutoff) { conn.socket.terminate(); continue; }
if (conn.socket.bufferedAmount > this.MAX_BUFFERED) { conn.socket.close(1013, 'backpressure'); continue; }
conn.socket.ping();
}
}
deliver(deviceId: string, frame: string): boolean {
const conn = this.byDevice.get(deviceId);
if (!conn || conn.socket.readyState !== WebSocket.OPEN) return false;
if (conn.socket.bufferedAmount > this.MAX_BUFFERED) return false;
conn.socket.send(frame);
return true;
}
}Operationally: enable SO_REUSEPORT, drain nodes on deploy with close code 1001 plus a client-side reconnect-after hint, and apply 0–30s randomized reconnect jitter so a regional network blip does not produce a synchronized 100M-handshake stampede.
Operational Checklist Before You Ship to 100M Devices
- Token lifecycle: refresh on app start and token-refresh callbacks; prune on
410/UNREGISTEREDwithin 24h. Expect 1.5–3% daily churn at 100M scale. - Payload budgets: APNs 4KB, FCM 4KB. Exceeding them truncates silently in some SDK paths.
- Collapse keys for chat and feed notifications; without them, reconnect floods amplify stale messages.
- Per-provider backpressure: never let one provider's
429storm consume global worker concurrency. - Error-budget alerting on provider 5xx/429 rates and on
T_acceptP99 — not on individual delivery failures. - Load test at 3x peak against APNs sandbox and FCM dry-run mode before any major campaign.
- Canary at 0.5% of devices with a separate collapse key namespace.
- Tier 3 reconciliation shipped and tested before launch, not after the first incident.
Pitfalls That Quietly Break Reliability at Scale
- Treating `200 OK` as delivery. It means accepted, not displayed. Only client instrumentation closes the loop.
- Retrying permanent errors. Bad tokens retried at fan-out volume is a self-DDoS.
- Using FCM normal priority for time-critical sends. Android Doze batches it into windows measured in minutes.
- Assuming a WebSocket replaces APNs. It cannot on iOS, and on Android an aggressive OEM battery manager will kill your foreground service anyway.
- Storing tokens without a `(platform, app_version, tenant)` composite key. Token collisions across environments produce spectacular misfires.
- No reconnect jitter. The thundering herd, not the crash, is what takes you down.
- Ignoring Low Power Mode. iOS defers background work aggressively; latency SLOs must be segmented by power state.
Sources & Technical References
- Apple, Sending notification requests to APNs — https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns
- Apple, Establishing a token-based connection to APNs — https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns
- Apple, Handling notification responses from APNs — https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns
- Google, Firebase Cloud Messaging documentation — https://firebase.google.com/docs/cloud-messaging
- Google, FCM HTTP v1 Send API reference — https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send
- Google, FCM error codes and status mapping — https://firebase.google.com/docs/cloud-messaging/send-message#error_codes
- Android Developers, Doze and App Standby — https://developer.android.com/training/monitoring-device-state/doze-standby
- Android Developers, Notification channels and importance — https://developer.android.com/develop/ui/views/notifications/channels
- IETF RFC 6455, The WebSocket Protocol — https://www.rfc-editor.org/rfc/rfc6455
- IETF RFC 8030, Generic Event Delivery Using HTTP Push — https://www.rfc-editor.org/rfc/rfc8030
- IETF RFC 9113, HTTP/2 — https://www.rfc-editor.org/rfc/rfc9113
- IETF RFC 8446, TLS 1.3 — https://www.rfc-editor.org/rfc/rfc8446
- AWS Builders' Library, Timeouts, retries, and backoff with jitter — https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/
- Google SRE Book, Addressing Cascading Failures — https://sre.google/sre-book/addressing-cascading-failures/
- Chris Richardson, Transactional Outbox pattern — https://microservices.io/patterns/data/transactional-outbox.html
- Martin Fowler, CircuitBreaker — https://martinfowler.com/bliki/CircuitBreaker.html
- M. Nygard, Release It! Design and Deploy Production-Ready Software, 2nd ed., Pragmatic Bookshelf, 2018.
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

Mastering Incremental Static Regeneration and Stale-While-Revalidate in Next.js
ISR is not a magic freshness switch — it is a two-layer cache contract between your framework and the CDN. Learn how to wire tag-based revalidation, align stale-while-revalidate windows, and avoid the production pitfalls that silently break caching.
Biggest Hack in the World: Binance Suffers Unprecedented $6.3 Billion Multi-Chain Cyberattack
A comprehensive technical autopsy of the $6.3B Binance hot-wallet breach attributed to TraderTraitor (Lazarus Group). How automated sweeping, compromised DevOps credentials, and cross-chain execution sparked the largest heist in financial history.

Landing Page Experimentation at 50M Sessions: Client-Side A/B Testing vs. Edge Decisioning vs. Server-Side Flags — Flicker, INP, and Cost per Million Decisions Benchmarked
At 50 million monthly sessions, where your A/B test makes its decision stops being a tooling preference and becomes a Core Web Vitals and P&L decision. This is a head-to-head benchmark of client-side DOM mutation, edge middleware bucketing, and server-side feature flags — measured on CLS flicker, INP, and true cost per million decisions.