Token lifecycle
Dead-token pruning, rotation by re-registration, and the opt-in staleDeviceAfter cutoff.
Push tokens die. Apps get uninstalled, subscriptions expire, browsers reset their storage. Hand-rolled push code usually keeps sending to those tokens forever, wasting quota and hiding real failures. better-push prunes them from the same result mapping every provider already returns.
Dead-token pruning
Each provider returns a tokenDead flag per delivery. When it is true, the
send pipeline calls disableDevice: the device row gets disabled_at and a
disabled_reason, and it is excluded from every future fan-out. The delivery
row is recorded as failed with the normalized code, so the history stays
auditable.
Nothing is deleted, and nothing throws. One dead token in a batch never affects the other devices in that send.
| Provider | Signal | Code | Device disabled |
|---|---|---|---|
web-push | 404 from the push service | invalid_token | yes |
web-push | 410 Gone | expired_token | yes |
fcm | messaging/registration-token-not-registered | invalid_token | yes |
fcm | messaging/invalid-argument | invalid_token | yes |
fcm | messaging/invalid-registration-token | invalid_token | yes |
apns | 410 Unregistered | expired_token | yes |
apns | 400 BadDeviceToken | invalid_token | yes |
expo | DeviceNotRegistered | invalid_token | yes |
The signal that arrives late
Every row above is reported in the send response - except the last one.
Expo answers a send with a ticket, not an outcome, and reports
DeviceNotRegistered later in a receipt fetched by ticket id. So the
pipeline schedules a receipt job at now + receiptDelay (default "15m")
and folds the answer onto the delivery row when it arrives.
This is the one place a push delivery reaches delivered:
| Receipt says | Delivery row | Device |
|---|---|---|
ok | delivered, with delivered_at set | untouched |
DeviceNotRegistered | failed, error invalid_token | disabled |
| nothing yet | left at sent | untouched, the job retries |
bp_delivery.delivered_at therefore means: confirmed received. It is
written for inApp rows, where the row in your database is the delivery, and
for push rows a provider has confirmed by receipt. It stays null for a
web-push, fcm or apns send, because those transports do not report one -
sent is as far as their acknowledgement goes.
Receipts need a queue. Without one there is nothing to run fifteen minutes
later, so Expo tickets are final and a dead token is pruned on its next send
instead - one notification later than it could have been, and nothing else
changes. See Expo push setup.
Failures that must not kill a token
The other half of the job is not disabling a device when the fault is yours. A credential or configuration error would otherwise wipe out every device in one send, and the tokens were perfectly valid the whole time.
| Provider | Signal | Code | Device disabled |
|---|---|---|---|
apns | 403 InvalidProviderToken / ExpiredProviderToken | provider_error | no |
apns | 400 DeviceTokenNotForTopic | provider_error | no |
apns | 429 TooManyRequests | rate_limited | no |
apns | 413 PayloadTooLarge | payload_too_large | no |
fcm | app/invalid-credential | provider_error | no |
fcm | messaging/authentication-error | provider_error | no |
fcm | messaging/message-rate-exceeded, quota-exceeded | rate_limited | no |
fcm | messaging/payload-size-limit-exceeded | payload_too_large | no |
| any | connection failure | network_error | no |
A run of provider_error means check your config
provider_error on every delivery for one provider is a credential problem -
a wrong p8 key, an expired service account, the wrong APNs gateway - not a
device problem. Nothing was disabled, so fixing the config is enough; there is
no re-registration to chase.
Rotation is just re-registration
Tokens rotate: FCM reissues a registration token, iOS hands out a new device token after a restore. There is no server-side rotation logic to write, and no endpoint to call.
The device row is unique on (provider, token), and POST {basePath}/devices
upserts against that key:
- Same token again - the existing row is refreshed:
last_seen_atmoves forward, anddisabled_atis cleared, so a device that was disabled comes back to life the moment it re-registers. - New token - a new active row. The old row lingers until its next send returns dead, at which point it is disabled by the rule above and stops being targeted.
So a client that re-registers whenever its token changes - on FCM's token refresh callback, or on every launch - is all that is required. Worst case, one notification is sent to a stale token once, fails, and prunes it.
await fetch("/api/push/devices", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ platform: "web", provider: "fcm", token: nextToken }),
});staleDeviceAfter
Some tokens go quiet without ever producing a dead-token signal - a phone that
was wiped, a browser profile that no longer exists. staleDeviceAfter is an
opt-in age cutoff for those:
export const push = betterPush({
// ...database, providers, session
// Skip devices not seen in 90 days.
staleDeviceAfter: 90 * 24 * 60 * 60 * 1000,
});It is a number of milliseconds, validated as a positive integer. When set,
notify() drops any active device whose last_seen_at is older than
Date.now() - staleDeviceAfter before building deliveries. Such a device:
- gets no delivery row - it was never targeted, so there is nothing to record.
- is not disabled - it stays active, and re-registering refreshes
last_seen_atand brings it straight back into fan-out.
It is off by default: leaving it unset targets every active device, which is the behavior every earlier phase had. Disabled devices are always excluded, whether or not you set it.
Pick a cutoff longer than your quietest user's gap
A cutoff shorter than the interval between a user's sessions silently stops their notifications. Ninety days is a reasonable floor; if your app notifies users who rarely open it, do not set this at all.
Putting it together
For a healthy deployment you need exactly two things:
- Clients re-register their token when it changes (and, cheaply, on launch).
- Providers are configured correctly, so
provider_errorstays rare.
Everything else - pruning, re-enabling, per-delivery history - is already
handled by the send pipeline. See Providers for how a single
notify() reaches all of them.