The shape of the pipeline
Push notifications in Swappr are emphatically not sent from the request thread. Every push goes through BullMQ:
The contract is:
- Producers (api processes) do the cheap bits: persist, broadcast over the wire, enqueue one job per non-sender recipient. Producer errors are swallowed — a failed enqueue must NEVER roll back the state change that triggered it. The message is already in Mongo, the socket broadcast has already fired; push is a latency-tolerant nice-to-have.
- Consumer (worker process) does everything else: recipient lookup, mute check, throttle / group state machine, FCM send, invalid-token cleanup. The consumer can crash, retry, run behind, fan out across multiple worker instances — the recipient’s experience just sees push latency increase.
The split is intentional and load-bearing: the API process’s request budget should never include FCM round-trips.
The three producers
| Producer | When it enqueues | File |
|---|
chat.message.service | After messages.insert + conversations.updateLastMessage, one job per non-sender participant. | apps/api/src/modules/chat/message.service.ts |
match.service | When a match flips to MUTUAL (both sides have liked). One job per side. | apps/api/src/modules/match/match.service.ts |
onboarding.service | When tenancy review flips to APPROVED. One job to the home owner. | apps/api/src/modules/onboarding/onboarding.service.ts |
All three enqueue via the PushQueuePort abstraction — a port whose production implementation is a thin BullMQ wrapper around PUSH_DELIVERY_QUEUE. The port is mockable in tests so service-level tests can assert on what was enqueued without spinning up Redis.
The chat producer passes senderId as senderName today (and the worker uses it verbatim as the push title). A UsersRepository.findById that returns display name doesn’t exist yet — this is documented in the code as a Phase 5 follow-up, NOT a Phase 5 bug. Same caveat for match — senderName is the other user’s id.
The job payload
export type PushJobKind = 'message.new' | 'match.new' | 'tenancy.approved' | 'critical';
export interface PushJobPayload {
kind: PushJobKind;
recipientUserId: string;
/** Required when kind === 'message.new'. */
conversationId?: string;
/** Required when kind === 'match.new'. */
matchId?: string;
/** Display name used in message.new / match.new titles. */
senderName?: string;
/** Override for kind === 'critical'. Required for that kind. */
title?: string;
/** Override body. For message.new this is the preview text. */
body?: string;
}
The four kinds and their rendered shape (per QUESTIONS.md §7.4):
kind | Title | Body | data.deepLink | collapseKey |
|---|
message.new | senderName (or Swappr) | first 140 chars of message text (or New message) | swappr://conversation/<conversationId> | conversationId |
match.new | New match! | You and <senderName> matched | swappr://match/<matchId> | matchId |
tenancy.approved | Tenancy approved | You can now post your home. | swappr://home | (none) |
critical | <title> (required) | <body> (required) | (none — caller-rendered) | (none) |
collapseKey is what makes a burst of pushes on the same conversation/match coalesce in the OS notification tray rather than stack five-deep.
The throttle + group state machine
The bulk of the worker’s complexity sits in handling chat bursts. A user receiving five messages in quick succession should NOT get five OS-level push notifications — they get one, then a brief silence, then a coalesced “3 new messages” group push.
The state machine, in words:
- Every
message.new push for a given (recipientUserId, conversationId) pair records its timestamp into a Redis sorted set with score = nowMs. Entries older than 30s are pruned in the same script.
- The post-pruning count is the “count within the last 30s window”.
- If the count is
1 (first hit), the push goes through as a single-shot send.
- If the count is
> 1, the worker bumps a per-key pending counter:
- If
pending < 3 (GROUP_AT), the worker returns — this push is suppressed.
- If
pending >= 3, the worker sends a single “N new messages” group push with data.grouped: 'true', then resets pending to 0.
In ASCII, a burst of 5 messages within ~20s:
msg 1 → window count 1 → single send (delivered) FCM call #1
msg 2 → window count 2, pending=1 → suppressed
msg 3 → window count 3, pending=2 → suppressed
msg 4 → window count 4, pending=3 → GROUP send "3 new messages" FCM call #2
→ pending reset to 0
msg 5 → window count 5, pending=1 → suppressed (waiting for the next group threshold)
In a Mermaid sequence:
The constants live in the worker and are exported for tests:
export const MESSAGE_WINDOW_MS = 30_000; // 30s rolling window
export const GROUP_AT = 3; // group every Nth suppressed message
The Redis key shape is push:throttle:<userId>:<conversationId>. Bulk-clearing all throttles for a user is SCAN push:throttle:<userId>:* + DEL.
The throttle is per (userId, conversationId), not global. Two simultaneous conversations send pushes independently. A high-volume conversation cannot starve other notifications.
The throttle ONLY applies to kind: 'message.new'. match.new, tenancy.approved, and critical always send single-shot. This is deliberate — these events are rare per user, and grouping them would harm UX more than it would save battery.
Mute semantics
The worker honours the user’s notificationsMuted flag BEFORE consulting tokens or throttle:
if (user.notificationsMuted && payload.kind !== 'critical') {
log.debug({ userId, kind }, 'push: muted, skipping');
return;
}
Precedence rules:
| User state | Kind | Outcome |
|---|
notificationsMuted: false, has tokens | any | Normal pipeline (throttle for message.new, single-shot for the rest). |
notificationsMuted: true | message.new, match.new, tenancy.approved | Skipped. No FCM call, no throttle bump. |
notificationsMuted: true | critical | Delivered. Bypasses the mute entirely. |
| any | any, no tokens registered | Skipped. |
| User missing | any | Skipped (treated as a stale job from before account deletion). |
Critical is reserved for events the user MUST see regardless of preferences — account banned, password changed, security incident. Phase 5 ships the kind dispatch; no Phase 5 producer enqueues a critical job yet.
REST + WebSocket parity
chat.message.service is called from both the REST handler (POST /chat/conversations/:id/messages) and the WebSocket handler (chat:message:send). The push enqueue happens inside the service, AFTER the dedup check — so:
- A fresh send (
deduped: false) → push enqueued.
- A retry (
deduped: true) → no push enqueued (the original send already enqueued one).
This means push is at-most-once per (senderId, clientMessageId) pair, matching the REST/WS broadcast contract. See Socket events — At-least-once delivery and dedup for the dedup mechanics.
Per QUESTIONS.md §7.1, Swappr uses FCM as the single push sender for both Android and iOS. iOS devices register FCM tokens via Firebase’s iOS SDK, which proxies to APNs server-side. From the worker’s perspective there is no if (platform === 'ios') sendApns(...) branch — every token is an FCM token.
The platform field on pushTokens[] is kept for analytics (split rates by platform, debug per-platform delivery issues) but plays no role in the send path.
The PushSender port
export interface PushSender {
send(req: PushSendRequest): Promise<PushResult>;
}
export interface PushResult {
successCount: number;
failureCount: number;
/** Tokens FCM reported as invalid (404 UNREGISTERED / 410 GONE / SenderId mismatch). */
invalidTokens: string[];
}
Two implementations:
FcmPushSender (production) — wraps firebase-admin.messaging().sendEachForMulticast(...). Requires FCM_SERVICE_ACCOUNT_JSON env. Returns invalidTokens based on FCM error codes.
InMemoryPushSender (dev / test) — records every send into an in-memory log, never fails, returns invalidTokens: []. Used when FCM_SERVICE_ACCOUNT_JSON is unset — see Deferred items.
Invalid token cleanup
FCM returns one of three error codes for tokens it can’t deliver to:
messaging/registration-token-not-registered — token was uninstalled / FCM rotated it.
messaging/invalid-registration-token — malformed.
messaging/sender-id-mismatch — token is for a different Firebase project.
The FcmPushSender collects these into result.invalidTokens[]. The worker then calls users.removeInvalidPushTokens(userId, invalidTokens) which pulls them off the user’s pushTokens array. Next push attempt → fewer dead tokens → less wasted FCM quota.
This means the explicit DELETE /me/push-tokens/:token endpoint is rarely strictly required — the cleanup happens automatically as soon as the next push fires. Clients still SHOULD call DELETE on sign-out to stop wasted push attempts and to respect the user’s intent immediately.
Failure isolation
Three layers of failure handling, in order of likelihood:
| Failure | Where | Consequence |
|---|
| Producer enqueue fails (BullMQ down, Redis down) | chat.message.service / match.service / onboarding.service | Logged at warn and swallowed. State change already committed; broadcast already fired. The recipient won’t get a push for this event — but the data is correct and they’ll see the message next time the app foregrounds or the next push arrives. |
| Worker recipient lookup fails | worker.push.delivery | BullMQ retries with backoff per its default config. After max retries the job moves to the failed queue. Logged. |
| FCM send fails (network, FCM 5xx) | FcmPushSender | The send method propagates — BullMQ retries. Transient failures resolve on retry; permanent ones (bad credentials) get loud in the failed-job queue. |
| FCM reports invalid tokens | FcmPushSender → worker | Tokens cleaned off the user. The send is otherwise considered successful (other tokens delivered). |
| User has no tokens | worker.push.delivery | Skipped silently. The user simply doesn’t have notifications enabled on any device. |
The contract is: a successful state change in Mongo NEVER fails because push failed. This is enforced at the producer layer via try/catch + log + swallow.
Why the worker, not the API process
Three reasons:
- FCM round-trips are slow and unbounded. A single API request shouldn’t carry a 200-2000ms FCM call. Off-thread is the right place.
- Bursts need queueing. A group chat with 20 participants where one user sends a message generates 19 push jobs. Doing those inline blocks the response on 19 FCM round-trips.
- The throttle is stateful and distributed. It can’t live in-process — multiple API instances would each maintain their own counts. The worker centralises it through Redis.
A future v2 might shard the worker by recipient (or by conversation) for further isolation, but Phase 5 ships a single worker class with BullMQ’s built-in concurrency.
Deferred items
Phase 5 explicitly does NOT ship the following — clients / operators should not depend on them existing yet:
- Real FCM credentials in production. The
FCM_SERVICE_ACCOUNT_JSON env is blank in dev → the worker uses InMemoryPushSender, which logs every push attempt but never calls a real device. The first production deploy will set this env and switch to FcmPushSender. See QUESTIONS.md §7.1 for the credential-provisioning task.
- Sender display name in push titles. Today the chat producer passes
senderId as senderName, so the push title is a 24-char ObjectId. Resolving to a display name needs either a small UsersLookupPort.getDisplayName(userId) or a denorm field on conversations.participants[]. Phase 5 follow-up.
- Trial-ending push. No periodic scan job exists;
subscriptions.trialEndsAt is captured but unused for push. Phase 7. See Paywall and launch phase.
- Delivered-receipt round-trip. The
Message.deliveredTo[] field is unpopulated today. Flipping it from the FCM delivery callback is a follow-up — see Socket events — out of scope.
- Per-conversation mutes. Only the global
notificationsMuted flag is wired.
- Image / rich-media push. Payload data is text only. FCM supports notification images but no producer attaches them.
- Critical-kind producers. The kind is in the dispatch table but no Phase 5 producer enqueues
critical jobs. Phase 7 will use it for account-state events.
See also