← All articles
7 min readiOS,Notifications,Scheduling,Engineering

Local Notifications on iOS: Triggers, Limits and Permission State

By Mykhailo Boichuk · Chief Technology Officer

In short

Flowo, the planning app from BBMM Technologies, schedules every reminder on iOS as a one-shot local notification at an absolute instant and uses no remote push. Before each pass it reads the system's pending list, counts requests it does not own, and keeps its own reminders inside a budget of 64. The scheduling path checks permission and never asks for it.

Local delivery only

Flowo delivers reminders on iOS through local notifications alone. The plan lives on the phone, so the phone already knows every time a reminder is due, and no server takes part in delivery.

The app's entitlements reflect that. An earlier build declared the push entitlement and the remote-notification background mode without using them. We removed both, because App Review asks what a declared capability does and we had no answer for these.

What remains declared is the time-sensitive notifications entitlement, the fetch and processing background modes, and a background task identifier for the daily refresh described at the end of this article.

One-shot triggers at an absolute instant

Every Flowo reminder is scheduled once, for one instant, and never as a repeating trigger. The instant is the start of a scheduled block minus the number of minutes stored on the task. A repeating trigger would keep firing after the plan changed, and the plan changes often.

At startup the notification layer loads the time zone database and asks the system for the device's IANA zone name. That zone becomes the local location used to convert each instant into a zoned date for the plugin. A zone name that the database does not know falls back to UTC, and the renamed Kyiv zone is first retried under its older spelling. Initialisation therefore never fails on a zone name.

A reminder is derived from the instant its block starts. A daylight saving change therefore needs no special case in the reminder code. The planner owns the question of which wall-clock hour a block belongs to.

A time that is already in the past is rejected before it reaches the system. The transport returns the error code scheduled_time_in_past, and the ledger records the failed attempt with a retry count.

Staying under the pending limit

iOS keeps a limited number of pending local notifications for an app, and the figure Apple has documented for local notifications is 64. The system keeps the requests that fire soonest and discards the rest without an error, so an app has to count for itself.

Flowo counts against the real state of the system. Each pass begins by reading the pending request ids. Ids that our ledger does not own belong to other parts of the app, and they are subtracted from 64 before any reminder is chosen.

The read can fail. In that case the budget becomes the number of reminders the ledger has already applied, capped at 64. The pass may refresh what exists and adds nothing new, and it is counted as failed.

Reminders are taken only from the next 48 hours, which is what makes the window roll forward. Our article on the reminder ledger covers the ordering inside the budget. A cursor stores how far reminders have been planned, and it advances only after a pass with zero failures and granted permission.

Stable identifiers

Each Flowo reminder keeps one notification id for as long as its key exists, so a changed reminder replaces the pending request in place. On iOS, adding a request with an identifier that is already pending replaces the earlier one.

  • The id is an FNV-1a hash of the reminder's key, masked to 31 bits, which keeps it a positive integer.
  • A hash of 0 becomes 1.
  • A collision steps forward by one and wraps at the top of the range.
  • Ids of pending requests that the ledger does not own are reserved, so a new reminder can never overwrite one of them.
  • An id already recorded for a key is reused.

Earlier builds stored notification ids on each scheduled block. A one-time migration cancels the ones that are still pending and sets its completion flag only when every cancel succeeded. A partial run repeats at the next start.

Permission state without a prompt

The scheduling code in Flowo reads the permission state and never requests it. The plugin is initialised with its alert, badge and sound request flags set to false, and the app delegate makes no authorisation request at launch. The request runs only from a screen the person opened.

For the read, the app prefers the answer the notification plugin gave at the last request. With no cached answer it calls the plugin's check, which reads the system's notification settings and shows nothing.

We do not rely on the general permission plugin for this on iOS. Its notification status is compiled in only when a build macro is set, and without the macro it reports denied after a grant.

iOS shows its prompt once, so the app treats any refusal as one that only the Settings app can change. When permission is missing, a pass still cancels reminders that are no longer wanted. It then stops before scheduling and reports the denial.

Foreground delivery and taps

Flowo shows a reminder even while the app is open. The app delegate is the notification centre delegate, and its foreground handler answers with banner, sound and badge.

The tap handler forwards the response to the superclass. The notification plugin hooks that method to deliver the tap to Dart, and it also calls the completion handler, so our override does not call it a second time.

A tap that launches the app arrives during the first startup phase, before any screen exists. The payload is held as a pending tap. The main shell takes it in a post-frame callback once it has mounted and routes it then.

Serialised passes and the daily refresh

Two reconcile passes in Flowo never run at the same time. Each pass is chained onto the end of the previous one, and the top-up that runs on app resume shares a single in-flight future. The ledger is written after every scheduled or cancelled request.

The daily refresh is a background task registered in the app delegate with a frequency of 24 hours. iOS decides when a background task actually runs, so we treat the refresh as a repair and rely on the passes that follow each schedule change and each return to the foreground.

Android differs in two places. Exact delivery needs a separate exact alarm permission, and the pending limit described here is an iOS constraint. The same budget of 64 is applied on both platforms.

Key takeaways

  • Flowo uses local notifications only on iOS and removed the unused push entitlement and remote-notification background mode.
  • Each reminder is a one-shot trigger at the block start minus the minutes stored on the task, converted with the device's IANA time zone.
  • Before choosing reminders, the reconciler subtracts pending requests it does not own from a budget of 64.
  • When the pending list cannot be read, the budget shrinks to the reminders already applied and nothing new is added.
  • Notification ids are 31-bit FNV-1a hashes, with ids of foreign pending requests reserved against collisions.
  • The scheduling path reads permission from the notification plugin and never triggers the system prompt.

Frequently asked questions

How many local notifications can an iOS app have pending?
The figure Apple has documented for local notifications is 64 per app, and the system keeps the ones that fire soonest. Flowo reads the pending list before each pass and keeps its reminders plus any other pending requests within 64.
Should reminders use repeating triggers or one-shot triggers?
Flowo uses one-shot triggers for every reminder. Its schedule is rebuilt often, and a one-shot request tied to a block's start can be replaced or cancelled by id when the block moves. A repeating trigger would keep firing after the plan behind it had changed.
How does Flowo handle time zones for iOS reminders?
It loads the time zone database at startup, reads the device's IANA zone name and converts each reminder instant into a zoned date in that zone. A name the database does not know falls back to UTC, and the renamed Kyiv zone is first retried under its older spelling. Reminder times come from the instant a block starts.
What happens to scheduled reminders when notification permission is denied?
The reconcile pass still cancels reminders that are no longer wanted, then stops before scheduling anything and reports the denial. The ledger keeps the desired reminders, so the next pass after permission is granted schedules them.
Why does a notification tap sometimes arrive before the app can handle it?
A tap that cold-starts the app is delivered while the notification layer initialises, before any screen is mounted. Flowo stores that payload as a pending tap. The main shell reads it in a post-frame callback after it mounts.

References

About the author

Mykhailo Boichuk

Chief Technology Officer

Mykhailo builds the systems behind the apps. He works on macOS and iOS performance, and on the sync problems that come with software that has to run offline.