Skip to main content

Scaling

Reference numbers

Measured with bun run load-test (100 000 subscribers, mock transports, 2 % failures, laptop-class M-series hardware):

Path100K pushesRate
Processors only (--mode=direct)~27 s3.7K msg/s
Through real BullMQ + Redis (--mode=queue)~29 s3.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.

AudienceSetupRough cost
< 50K2 vCPU / 4 GB VPS, everything in Compose~$8/month
50K – 500K4 vCPU / 8 GB + separate Mongo~$25/month
500K – 5M3 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

SymptomTurn
Campaign takes too long, workers idleAdd worker replicas (--scale worker=3). Queues are shared.
Campaign takes too long, workers busy, Mongo CPU highMove Mongo to its own host / replica set; give WiredTiger more cache (MONGO_CACHE_GB).
429 TooManyRequests from APNsLower APNS_CONCURRENCY or PUSH_RATE_LIMIT_MAX.
QUOTA_EXCEEDED from FCMLower FCM_CONCURRENCY. The OAuth token is shared via Redis so replicas do not multiply token requests.
Web push slowPer-endpoint-host latency dominates (Apple, Mozilla, Google). Raise WEBPUSH_CONCURRENCY; it is I/O bound.
API 503 under a subscribe burstScale the API; raise SUBSCRIBE_RATE_LIMIT if the traffic is legitimate.
Redis memory growingCompleted 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-lru to "save memory". BullMQ job hashes are evicted silently.
  • Do not shard by app. Every query is already scoped by appId and 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.