better-push
Delivery channels

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.

ProviderSignalCodeDevice disabled
web-push404 from the push serviceinvalid_tokenyes
web-push410 Goneexpired_tokenyes
fcmmessaging/registration-token-not-registeredinvalid_tokenyes
fcmmessaging/invalid-argumentinvalid_tokenyes
fcmmessaging/invalid-registration-tokeninvalid_tokenyes
apns410 Unregisteredexpired_tokenyes
apns400 BadDeviceTokeninvalid_tokenyes
expoDeviceNotRegisteredinvalid_tokenyes

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 saysDelivery rowDevice
okdelivered, with delivered_at setuntouched
DeviceNotRegisteredfailed, error invalid_tokendisabled
nothing yetleft at sentuntouched, 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.

ProviderSignalCodeDevice disabled
apns403 InvalidProviderToken / ExpiredProviderTokenprovider_errorno
apns400 DeviceTokenNotForTopicprovider_errorno
apns429 TooManyRequestsrate_limitedno
apns413 PayloadTooLargepayload_too_largeno
fcmapp/invalid-credentialprovider_errorno
fcmmessaging/authentication-errorprovider_errorno
fcmmessaging/message-rate-exceeded, quota-exceededrate_limitedno
fcmmessaging/payload-size-limit-exceededpayload_too_largeno
anyconnection failurenetwork_errorno

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_at moves forward, and disabled_at is 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.

on the client, whenever the token changes
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:

src/push.ts
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_at and 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:

  1. Clients re-register their token when it changes (and, cheaply, on launch).
  2. Providers are configured correctly, so provider_error stays 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.

On this page