Push Notifications: Managing the Token Lifecycle Nobody Talks About
Permission as a spendable one-shot resource, the token registry your backend must own, deep links as versioned contracts, sending discipline with collapse keys and quiet hours, and the four health metrics.
Push notifications look like messaging but behave like distributed state management. A "simple" push travels: your backend → provider API → Apple/Google's push service → the OS → maybe the user's eyes — and every hop has state that can silently rot: permissions get revoked, tokens expire, apps get reinstalled, deep links point at screens that no longer exist. The teams whose pushes "just work" aren't lucky; they manage the lifecycle. Here's that lifecycle, stage by stage.
Stage 1: permission — a one-shot resource with a state machine
Permission isn't a boolean; it's a state machine: not-determined → granted / denied, with iOS's provisional (quiet delivery without a prompt) and Android 13+'s runtime prompt joining the party. Two operating rules:
- Ask in context or don't ask. The cold-start prompt converts terribly and a denial is nearly permanent (re-enabling means a trip to Settings that ~nobody makes). Trigger the ask from a moment of demonstrated value — after the first order, when enabling alerts for something specific. iOS provisional authorization is a lovely middle path: deliver quietly first, earn the loud permission later.
- Track the state server-side. Your backend should know each user's pushability — because "notify the user" logic needs to route around denial (fall back to email/in-app), and because the aggregate metric "what % of actives are pushable" is an early-warning system for over-notification. When it trends down, you're burning the channel.
Stage 2: the token registry — your table, your rules
Even using a provider like OneSignal that abstracts raw APNs/FCM tokens, you own a mapping — user ↔ devices/subscriptions — and it deserves a real table:
CREATE TABLE push_subscriptions (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
provider_id VARCHAR(64) NOT NULL, -- OneSignal subscription / raw token
platform VARCHAR(16) NOT NULL, -- ios | android | web
app_version VARCHAR(16), -- deep-link compatibility gate!
last_seen_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP NULL,
UNIQUE (provider_id)
);
The rules that keep it truthful: upsert on every app launch (refreshing last_seen_at and app_version), bind tokens to the authenticated user and re-bind on login/logout (the shared-device leak from the OneSignal post lives here too), and treat provider feedback — delivery failures, unsubscribes from webhooks — as revocation events. A registry that only ever grows is lying to you; expect healthy churn of 5–10% monthly from reinstalls and device upgrades alone.
Stage 3: deep links — the contract nobody versions
The tap is the product moment, and it's where old app versions meet new payloads. That data: {deep_link: "app://orders/4211"} block is an API whose clients update on the user's schedule, not yours — which means additive evolution rules apply with extra force:
- Route through an indirection layer in-app. The push carries an intent (
{"screen": "order", "id": 4211}), a router maps intent → navigation, and unknown intents fall back to home (never crash, never dead-end). Shipping screen paths directly in payloads couples marketing pushes to your navigation stack — a coupling you'll rediscover during every redesign. - Gate by
app_versionat send time. That column in the registry lets the backend skip sending "open the new rewards tab" to versions that don't have one — or send them a web fallback link instead. - Handle the cold-start race. A tap that launches the app delivers the payload before your navigation is ready; queue the intent and replay it post-init, or the classic "tapped the push, landed on home screen" bug ships with v1.
Stage 4: sending discipline — the part that protects the channel
Every push spends user tolerance; the budget is smaller than product managers hope. The mechanics that stretch it: collapse keys so five status updates render as one current notification; quiet hours in the user's timezone (schedule-per-timezone, or provider "intelligent delivery" — nobody loves your 4 a.m. feature announcement); per-category preferences honored server-side (transactional / activity / marketing as separate toggles — see the preference engine post); and frequency caps on non-transactional sends, enforced with a counter per user per day, because three campaigns and two lifecycle nudges in one afternoon reads as one thing: spam.
Stage 5: cleanup and the metrics that matter
Quarterly hygiene: expire registry rows unseen for 90+ days, reconcile against provider unsubscribe lists, and delete revoked rows past your audit window. And put four numbers on a dashboard — pushable-rate (of active users), delivery rate (sent → device), open rate per category (transactional should clear 20%+; marketing will humble you), and disable rate within 24h of a send — that last one is the smoking gun that tells you exactly which notification type is burning the channel down.
Push infrastructure done well is invisible: the right person, the right moment, the tap that lands exactly where it should. Done carelessly, it's the fastest way to teach ten thousand users to disable the one channel you can't buy back. The difference is this lifecycle, managed on purpose.
This post covered one channel deeply; the finale zooms out to the unified system — push, email, in-app — with preferences and routing done once, properly. Or bring me your open rates and we'll find where the tolerance is leaking.