Skip to content

Expire push notifications 15 minutes after their event - #100

Merged
eivindjonassen merged 2 commits into
mainfrom
fix/notification-lifetime
Nov 1, 2026
Merged

eivindjonassen merged 2 commits into
mainfrom
fix/notification-lifetime

Conversation

@eivindjonassen

Copy link
Copy Markdown
Member

Summary

Push notifications now expire 15 minutes after the event that produced them. This applies in the broker and at Apple and Google.

Before

  • Queued notifications never expired. prune() only deletes failed rows, and rate-limit and network retries had no limit. The backoff caps at 256s, so a rate-limited notification was retried about every 4.3 minutes until it was delivered, resolved, paused, or the device was disabled. Resend rate-limited receipts with backoff, but never after resolution #97 extended this to rate-limited receipts.
  • Every push was sent with ttl: 7 * 24 * 60 * 60, so APNs and FCM kept it for 7 days and delivered it whenever an offline phone came back.

After. notificationLifetimeMs = 15 * 60_000 in expo-push.ts, measured from created_at_ms, which is when the broker queued the event.

Path Now
First send expireQueued() runs before each batch and fails queued rows at or past the lifetime as failed / EXPIRED. canSendQueued checks again just before each send.
HTTP 429 or 5xx, network error, rate-limited ticket or receipt markRetry fails the row as EXPIRED if the next attempt would land at or past the lifetime. Otherwise it backs off as before.
Receipts for sent pushes Still processed at any age. DeviceNotRegistered still disables the device.
Paused or resolved Unchanged (PAUSED, RESOLVED), and checked before expiry.

The push sends Expo expiration: floor((created_at_ms + 15 min) / 1000), an absolute Unix time, in place of ttl. Retries keep the same expiration. Expo uses ttl over expiration when both are present, so ttl is removed. collapseId, tag, priority and channelId are unchanged.

Effects to know about

  • Receipts are checked 15 minutes after sending, which equals the lifetime. So a rate-limited receipt for a first send now ends EXPIRED and isn't resent. Resend rate-limited receipts with backoff, but never after resolution #97's receipt resend path is effectively unreachable, and a push that late isn't wanted anyway. Resend rate-limited receipts with backoff, but never after resolution #97's RESOLVED guard stays in place.
  • An event that reaches the broker more than 15 minutes after it happened expires on arrival. That would happen if the plugin delivers it after a long broker outage.
  • Old queued rows for disabled devices now expire too. Before, they stayed queued and could be sent after re-pairing.
  • The 7-day expiresAtMs in the encrypted routing envelope is unchanged. The phone checks it when a notification is tapped from the notification center.
  • Expo warns that short TTLs can drop pushes to Android devices in doze mode. Pushes are sent with priority: "high", which reduces that risk.

docs/NOTIFICATIONS.md and docs/SPEC.md describe the lifetime. Neither documented the old TTL or retry behavior. No schema change.

Verification

  • Broker tests: 221 pass. 13 new or updated tests failed on main: the expiration field with no ttl, retries keeping the original expiration, rows at or past the lifetime not sent, each retry kind not resending past the lifetime, a row just inside the lifetime still sent, and a queued row expiring while a ticketed row keeps its receipt. One guard passed before and still passes: unregistered devices are disabled from receipts long after the lifetime.
  • Broker typecheck and build, pnpm lint
  • CRAP: 6 or below for the touched functions
  • Tests cover the changed behavior.
  • Platform behavior is verified on the affected targets, or the gap is documented. Tested with stubbed Expo responses and an in-memory database. After deploying, check that a push sent while a test phone is offline for over 15 minutes doesn't arrive once it reconnects.
  • No credentials, addresses, identifiers, user content, private paths, or deployment files are included.
  • Documentation reflects the resulting behavior.

eivindjonassen and others added 2 commits November 1, 2026 22:23
Queued notifications never expired, rate-limit and network retries had
no limit, and every push was sent with a seven-day ttl, so APNs and FCM
delivered it whenever an offline phone reconnected. The broker now
fails a notification as EXPIRED once it is 15 minutes old, never sends
or resends it after that, and sends Expo an absolute expiration at the
same time instead of the ttl. Receipts for pushes already sent are
still checked, so unregistered devices are still disabled.
@eivindjonassen
eivindjonassen merged commit 5401923 into main Nov 1, 2026
4 checks passed
@eivindjonassen
eivindjonassen deleted the fix/notification-lifetime branch November 1, 2026 21:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant