Push Notifications
Expo push, Convex actions
Overview
Push delivery is a two-step write: the mutation that triggers the event writes the `notifications` row and enqueues a Convex action that sends the push via Expo's API. Decoupling means the originating mutation never fails because a push attempt failed.
How it works
On app launch, `useNotificationToken` runs `Notifications.getExpoPushTokenAsync()`. The token is saved to the user via `users.registerPushToken`, which dedupes against existing tokens.
When a server mutation writes a notification, it calls `ctx.scheduler.runAfter(0, internal.pushes.send, { ... })`. The action loads the recipient's push tokens and calls Expo's POST /send endpoint with the payload.
Tokens that come back as DeviceNotRegistered are removed from the user row, keeping the list clean over time.
Notification payloads include `route` and `params` so the on-tap deep link routes directly to the relevant screen.
On sign-out we don't immediately delete tokens — we leave them until the next launch (which won't happen with this user), and the next user's `registerPushToken` will overwrite.
Key decisions
Push as a scheduled action, not inline
Pushing inside the mutation would couple write success to network success. Pushing inside a scheduled action keeps the write atomic and the push fire-and-forget. Failures are logged but never throw.
Expo push over direct APNs / FCM
Expo's push service abstracts away APNs/FCM creds, certificate rotation, and dead-letter handling. We could be on raw APNs by now — but our notification volume doesn't justify the operational cost. Expo's monthly free tier is plenty.
Token dedupe per user, not per device
A user can have at most a few tokens (one per device). We dedupe inside the user row — the array can hold up to N tokens, and the registration writes prune duplicates. Stale tokens are pruned on send failure.