Push notifications are where good apps go to become annoying. The moment the pipeline exists, every feature wants to use it, and six months later your users have notifications off and you've burned the channel for the one message that mattered.
So this feature started with an editorial rule, written in the plan before any code: every notification must answer "what would I thank the app for telling me?" — with explicit anti-goals: no engagement spam, no "you haven't shopped in a while" guilt, no badge-count anxiety. The engineering below exists to serve maybe three genuinely thankable messages.
TL;DR — The highest-value notification (a weekly shopping reminder) needs no backend at all — it's scheduled on-device, so real value shipped before any sender existed. The remote pipeline: tokens on the user profile, registration on the first relevant moment, never app launch (the OS prompt must arrive in context); tokens cleared on logout so shared devices can't receive the previous user's pushes; deep links handled from the tap listener and the cold-start API everyone forgets. And the admin broadcast carries an existing announcement rather than being its own message — a push that points at a record can't drift out of sync with it.
(Part 23 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
Local before remote — ship value before plumbing
Notifications come in two species with wildly different costs:
- Local scheduled notifications need no tokens, no server, no permission beyond the OS prompt — the app schedules them on-device.
- Remote push needs the whole pipeline: token registration, tokens stored server-side, a sender, delivery receipts.
The plan's first insight was that CannyCart's most valuable notification is local: "Saturday shop 🛒 — time to check your list," weekly, on the day and time the user picks in Settings. That shipped in the first phase, before a single Lambda existed. If your best notification is a reminder, you may not need the expensive half for months.
One small honesty decision inside it: the reminder uses generic copy rather than an item count. Rescheduling the notification with a fresh count on every list change is a lot of churn to keep a number true — and a stale count ("14 items!" when there are 3) is worse than none. The count appears only when the app was opened recently enough to actually know it. A notification that might lie gets demoted to one that can't.
The plumbing, and where the permission prompt lives
The remote half is conventional Expo machinery — expo-notifications (native module, dev-client rebuild; Expo Go can't receive push at all on recent SDKs), an Android channel created up front, the Expo push token plus the native device token stored on UserProfile, per-category preferences in a JSON blob alongside them.
The decision worth stealing: registration runs on the first relevant moment, never at app launch. The OS permission prompt appears when the user turns on a Settings toggle or taps "remind me" — a moment where the request explains itself — not as an ambush over the splash screen. (App review agrees, by the way: a later App Store rejection in this project was precisely about a permission flow that didn't lead straight to the system prompt in context. Permissions are UX, and the reviewers treat them that way.)
Tokens follow a last-device-wins rule for v1 — each sign-in overwrites the stored tokens. A per-device token table is the known upgrade; a solo-scale app doesn't need it yet, and the plan says so rather than pretending.
Token hygiene: the logout that clears them
The easy bug to ship: user A signs out on a shared phone, user B signs in, and user A's server-side record still holds a token pointing at this physical device — so A's notifications light up B's lock screen. The fix is boring and non-optional: sign-out clears the push tokens on the profile before the session ends.
The same principle shows up one layer down, as a preview of Part 25: this project mirrors production data into the dev environment with PII masked — and push tokens are nulled, never copied, in that mirror. A copied token is a loaded gun: any test send in dev would hit a real customer's phone. Tokens are the one field where "masked" isn't enough; only absence is safe.
Deep links: the cold-start half everyone forgets
Every notification carries a data.url, and tapping routes to it. The trap is that there are two tap paths: the response listener (app running or backgrounded) and getLastNotificationResponseAsync (app dead — the tap is what launched it). Handle only the listener and your deep links work in every test you'll casually run, then silently dump real users on the Home screen — because real users' apps are cold. Both paths route through the same handler; the cold-start one is checked once on mount.
Broadcasts: a push is a pointer, not a message
The sending half arrived once the web admin console existed: a broadcast composer for "notify everyone" moments. Its central design decision keeps the whole thing small:
A push carries an existing announcement — it is never its own message. The app already has an announcements system (a durable record, an in-app archive screen that renders it). So the broadcast takes an announcement's title and first paragraph, deep-links to the What's-new screen, and adds no new content store. The per-user notification-feed model the standard pattern calls for was deliberately skipped: the announcement is the record, and a push that merely points at one cannot drift out of sync with it — no "the notification says X but the app says Y."
The rest is guardrails: one custom mutation as the single send path (a second entry point on a user row for support follow-ups goes through the same one), a send log with delivery receipts so "did it actually send?" has an answer, and a quiet-hours guard so an admin publishing release notes at midnight doesn't buzz every phone at midnight.
What I took away
- Write the editorial rule before the pipeline. "What would I thank the app for telling me?" killed more notification ideas than any rate limiter would have.
- Local first. If your best notification is a reminder, it needs no backend — ship it before the expensive half.
- Ask for permission in context, never at launch. The OS prompt is UX; both your users and app review grade it.
- Clear tokens on logout, null them in data mirrors. A push token is the one credential people forget is a credential.
- Handle the cold-start tap. The response listener alone works in every demo and fails for every real user.
- Point, don't copy. A notification that references a durable record can't contradict it.
Next up
Part 24 is the sprint nobody blogs about: what app-store review actually forces you to build — the public site, the legal pages, and account deletion done properly.
What's the one notification your app sends that users would genuinely thank you for — and how many of the others would survive that test?
Top comments (0)