Scaling
Reference numbers
Measured with bun run load-test (100 000 subscribers, mock transports, 2 % failures, laptop-class M-series hardware):
| Path | 100K pushes | Rate |
|---|---|---|
Processors only (--mode=direct) | ~27 s | 3.7K msg/s |
Through real BullMQ + Redis (--mode=queue) | ~29 s | 3.4K msg/s |
The bottleneck is Mongo writes (an event and a lastNotifiedAt update per push), not the transports. A 4 vCPU VPS handles an 84K-subscriber campaign in well under a minute.
| Audience | Setup | Rough cost |
|---|---|---|
| < 50K | 2 vCPU / 4 GB VPS, everything in Compose | ~$8/month |
| 50K – 500K | 4 vCPU / 8 GB + separate Mongo | ~$25/month |
| 500K – 5M | 3 worker nodes + Mongo replica set | ~$120/month |
How a send flows
POST /send → count segment → cursor over subscriptions
→ chunks of 1000 → one job per push on push-ios / push-android / push-web
→ worker: delivery rules → transport → event + counters
Three queues, one per platform, on purpose: APNs, FCM and web push have different rate limits and failure modes, and a stall on one must not stop the others.
Knobs, by symptom
| Symptom | Turn |
|---|---|
| Campaign takes too long, workers idle | Add worker replicas (--scale worker=3). Queues are shared. |
| Campaign takes too long, workers busy, Mongo CPU high | Move Mongo to its own host / replica set; give WiredTiger more cache (MONGO_CACHE_GB). |
429 TooManyRequests from APNs | Lower APNS_CONCURRENCY or PUSH_RATE_LIMIT_MAX. |
QUOTA_EXCEEDED from FCM | Lower FCM_CONCURRENCY. The OAuth token is shared via Redis so replicas do not multiply token requests. |
| Web push slow | Per-endpoint-host latency dominates (Apple, Mozilla, Google). Raise WEBPUSH_CONCURRENCY; it is I/O bound. |
API 503 under a subscribe burst | Scale the API; raise SUBSCRIBE_RATE_LIMIT if the traffic is legitimate. |
| Redis memory growing | Completed jobs are trimmed (removeOnComplete/removeOnFail). If it still grows, check that the worker is running — an idle queue is a full queue. |
Things that do not scale the way you might expect
- Do not run several APNs JWT signers with different clocks. A token is refreshed at most every 20 minutes; more frequent refreshes earn
TooManyProviderTokenUpdates. The worker caches the JWT per app. - Do not put Redis behind
allkeys-lruto "save memory". BullMQ job hashes are evicted silently. - Do not shard by app. Every query is already scoped by
appIdand indexed; one cluster serves many tenants. - Rate limits are per API process. Fine for abuse protection; if you need exact global limits, front the API with a proxy that has them.
Bun and HTTP/2
APNs requires HTTP/2 and the transport is written against node:http2 only — no Bun.* APIs. Bun's HTTP/2 client is version-sensitive under load. If you ever see stalls on the iOS queue at high concurrency, switch only the worker's Dockerfile to a Node.js base image; the code does not change.