Tags scheduled notificationsexpo-notificationsreact nativeandroid alarmmanageraccount switchingmobile app bugs
All posts

scheduled notifications

Scheduled notifications outlive the account that created them

After a sign-out, the previous user's tasks kept firing on my lock screen. Scheduled notifications live in a per-device OS queue that auth state never touches.

August 11, 2026 6 min

I switched accounts in my own app. Signed out, signed in as someone else, confirmed the new account’s data rendered correctly. It did.

The next morning my lock screen showed a task title from the account I had signed out of. That task does not exist under the current user. The account that created it is gone from the app. The notification fired anyway, exactly on schedule.

Scheduled notifications are not your app’s state

Here is the model I had wrong. I treated scheduled notifications as data my app owns, so signing out should dispose of them along with everything else.

What actually happens is that scheduling a notification is not remembering something. It is filing a request with the operating system: show this text at 9am on the 12th. Android and iOS keep that request in a per-device queue. It fires whether or not your app is running, whether or not it was force-stopped, and whether or not anybody is signed in. That independence is the entire point of the feature.

So a sign-out is a non-event from the queue’s perspective. Some in-memory state changed inside a process; the OS ledger was never touched.

lives wheresurvives sign-out
Task dataserver + app memoryno
Screen stateapp memoryno
Scheduled notificationsOS queue, per deviceyes

Only the third row behaves differently, and in code all three look equally like “our app’s stuff.” That is why this is easy to miss and hard to spot in review.

Cancel when the session ends, not when the next one starts

The fix is small. What matters is where it goes.

It belongs at the moment you let go of the previous account, not after the new sign-in succeeds. If the new sign-in fails, or the user backs out of the provider sheet, those notifications are already somebody else’s - and now nobody is going to clean them up.

export async function cancelAllLocalNotifications(): Promise<void> {
  try {
    // 1. queued, not yet fired
    await Notifications.cancelAllScheduledNotificationsAsync();
    // 2. already fired and sitting in the tray
    await Notifications.dismissAllNotificationsAsync();
  } catch (e) {
    log.error('[notifications] cancelAllLocal failed', e);
  }
  // 3. full-screen alarms - a separate channel the two calls above cannot see
  await fsAlarm.cancelAll();
}

Three branches, and I shipped with only the first one before hitting the same report again.

  • Queued - future deliveries. The obvious one.
  • Tray - already delivered and still visible. Not in the schedule anymore, still on the user’s screen.
  • Full-screen alarms - this app uses Android’s AlarmManager directly for hard alarms. Different channel entirely; the expo-notifications calls do not reach it.

We call all three “notifications,” but they live in three places. Miss one and the symptom degrades from always to sometimes, which is a much worse bug to chase.

If you cannot enumerate it, keep a ledger

That third branch has its own problem: AlarmManager has no “list everything I have scheduled” API. You can set an alarm and you can cancel a specific one, but you cannot ask what is pending.

If you cannot query the thing you need to delete, you have to write it down as you go.

// AlarmManager has no enumeration API, and expo-notifications cannot see
// these either (separate channel). So every id that passes through
// schedule/cancel gets recorded, and cancelAll() works off that ledger.
function addToRegistry(fid: string): void {
  const cur = readRegistry();
  if (cur.includes(fid)) return;
  DeviceStore.set(CacheKeys.FS_ALARM_FIDS, [...cur, fid]);
}

The ledger can drift from reality - reinstall the app and it empties while alarms may persist, and the reverse happens too. That drift is cheap in both directions: cancelling a nonexistent alarm is a no-op, and an alarm missing from the ledger leaves you exactly where you were before the ledger existed. With no enumeration API there is no version of this that is provably complete, so the design goal is to be wrong in the direction that costs least.

One practical bonus: this approach touches no native code, so it ships as a JavaScript-only update with no store review.

The same file contained the opposite bug

Everything above is about notifications that would not go away. While reading that code I found the inverse: on every cold start, the app was cancelling all of today’s task notifications.

There is a cleanup pass that runs at launch. It reads the list of live tasks and cancels any queued notification whose task is no longer in that list - so deleting a task stops its reminder. Reasonable.

The call fetching that list was missing its date range. The data layer saw no range, took an early-return path, and never attached its listener. The list was permanently empty.

Then the cleanup pass read that empty list and concluded:

No live tasks exist. Therefore every queued notification is stale. Cancel them all.

No exception. No warning. The code did precisely what it was told.

The defect is that “not loaded yet” and “genuinely none” are both the empty array. A human distinguishes them instantly; the code cannot. And in this particular position the two readings produce opposite actions - one means do nothing, the other means delete everything.

Deletion decisions are asymmetric

So the rule changed. Only cancel what you can positively identify as stale:

// If we cannot read when this fires, leave it alone.
const fireMs = triggerFireMs(n.trigger);
if (fireMs == null || fireMs > windowEndMs) return Promise.resolve();

Readable trigger, and inside the window we actually verified. Anything uncertain survives.

That is not generic caution - it comes from comparing the two failure directions concretely:

  • Keep a notification you should have cancelled: a deleted task pings once more. The user thinks “huh, I deleted that,” and moves on. Self-correcting on the next pass.
  • Cancel a notification you should have kept: the thing the user was counting on never fires. They do not learn they missed it, because a notification that does not arrive leaves no trace.

The second is worse and, critically, invisible. This bug never came in as a report. It surfaced because I was in the file for an unrelated reason.

What to check in your own notification code

One: the queue is attached to the device, not the session. Clear scheduled notifications at every point where the user identity changes - sign-out, account switch, account deletion. Do it when you release the old session, not after the new one succeeds.

Two: “notifications” is not one place. Queued, delivered-and-visible, and any separate alarm channel each need their own teardown. Partial cleanup turns a deterministic bug into an intermittent one.

Three: in any code that deletes, distrust empty. If an empty collection can mean either “nothing exists” or “not loaded yet,” and your code treats it as the former, that code will eventually delete everything. When the deletion is also silent, you will not find out for months.

The 3D asset post from the same app had the same shape: quiet numbers do not get looked at. Both fixes ended the same way - stop relying on remembering to check, and make the code check every time.

Measured 2026-08-11 on Expo SDK 56, expo-notifications, React Native 0.85, Android. The full-screen alarm branch is Android-specific (AlarmManager); the queued and tray branches apply to both platforms.

Previous Turborepo said cache hit and restored nothing