Systems/Push Notifications
06 / 06

Push Notifications

Expo push, Convex actions

ExpoPushActions

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

1

On app launch, `useNotificationToken` runs `Notifications.getExpoPushTokenAsync()`. The token is saved to the user via `users.registerPushToken`, which dedupes against existing tokens.

2

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.

3

Tokens that come back as DeviceNotRegistered are removed from the user row, keeping the list clean over time.

4

Notification payloads include `route` and `params` so the on-tap deep link routes directly to the relevant screen.

5

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.