We built Mornio, an iPhone alarm app using AlarmKit. This is a write-up of three things we got wrong on the way to shipping it. All three are the kind of mistake that looks correct in code review, passes a manual test, and only shows itself at 6am on a phone that is not yours.

Everything below is from a shipping app. Where a detail is specific to our product rather than to AlarmKit, we say so. If you never install Mornio, the three lessons should still be worth your time.

Why we couldn't use notifications

Before iOS 26, a third-party app could not schedule an alarm that sounds through silent mode or a Focus. The usual workaround was a local notification, sometimes with looping audio behind it. Both can be suppressed by the system, which is a tolerable failure for a reminder and a total failure for an alarm.

AlarmKit, introduced in iOS 26, lets an authorised app schedule a genuine system alarm: it breaks through silent mode and Focus, presents a full-screen alert, and appears on the Lock Screen and in the Dynamic Island.

We took the strict version of that bet. Mornio's alarm path is AlarmKit only, with no notification-based alarm fallback, and the app requires iOS 26.1. The cost is real: we cannot run on older iPhones at all. The reasoning is that a fallback here is not a safety net, it is a second code path that fails quietly on the one morning that mattered. In the build without AlarmKit available, every scheduling entry point throws rather than substituting something weaker:

// Non-AlarmKit build: fail loudly, never silently downgrade.
func scheduleProtected(_ plan: AlarmPlan, includeBackup: Bool) async throws -> ScheduledAlarmIDs {
    throw AlarmSchedulingError.unsupportedOS
}

Worth separating two things that often get merged: we still use UserNotifications. It sends the pre-sleep check reminder, a low-battery warning, a setup-incomplete nudge and a couple of status messages. None of them is an alarm. "No notification fallback" means no notification alarm path, not no notifications.

Mistake 1: we scheduled the backup alarm at the same time

A protected alarm in Mornio schedules a primary and a backup. The first version scheduled both for the same instant, on the theory that two alarms are more reliable than one.

They are not. Two system alarms firing at the same moment is a duplicate-alert bug, not redundancy. The user gets two competing full-screen alerts for a single wake-up, dismissing one leaves the other mid-presentation, and the thing you built for reliability is now the thing that looks broken.

The backup exists to cover a different failure — not "the alarm did not fire" but "the alarm fired and the user went back to sleep." That failure happens minutes later, so the backup belongs minutes later:

/// When the per-occurrence standby backup fires, relative to the primary. Lives outside
/// the `canImport(AlarmKit)` wall so tests can assert that no re-ring fallback is ever
/// scheduled for the same instant — two alarms firing together is the duplicate-alert
/// bug, not extra protection.
enum AlarmBackupTiming {
    static let offsetMinutes = 4
    static var offsetSeconds: TimeInterval { TimeInterval(offsetMinutes * 60) }
}

The placement of that constant is the part worth copying. It is deliberately declared outside the #if canImport(AlarmKit) block, so a unit test can assert the offset is non-zero without importing AlarmKit or running on a device. The invariant is "no two alarms for the same instant," and it is cheap to protect once the constant lives somewhere a test can reach.

If you are adding redundancy to anything time-based, the general form is: work out which failure the second attempt is actually covering, and let that decide the offset. Redundancy at zero offset is usually duplication.

Mistake 2: we trusted our own state

Mornio shows a status of "Protected" when an alarm is fully armed. The first implementation set that from app state: schedule the alarm, get no error, record protected, render the badge.

That is a claim about the system made from memory of a call that has already returned. App state and system state can diverge — a scheduling call that partly succeeded, a restore path, a user revoking authorisation in Settings. When they diverge, the badge is confidently wrong about the one thing the user is relying on.

So we ask AlarmKit instead:

/// IDs AlarmKit currently has scheduled, used to *verify* a claim of protection.
func liveScheduledIDs() async -> Set<UUID>

Before the UI claims protection, the IDs we think we scheduled are checked against the IDs the system says it holds. The rule we settled on is that "Protected" appears only when every required component actually succeeded: authorisation granted, primary scheduled, backup scheduled, verification method available. If any part fails, the status says so with a specific reason, and scheduling throws rather than returning a partial success that reads as a whole one.

Two smaller things came out of this. Scheduling needed to be idempotent, because re-checking an already-scheduled alarm should confirm it rather than fail on a duplicate identifier. And the errors are worth enumerating properly — unsupportedOS, notAuthorized, noFireDate, and a wrapped underlying case — because "could not schedule" is not something you can write a useful fix action for.

The general lesson: if your UI asserts something about system state, read it back from the system. A returned function call is weaker evidence than it feels like.

Mistake 3: we treated one permission as two names for the same thing

AlarmKit has its own authorisation, separate from notification authorisation. We initially modelled them together, which produced a status screen that could be wrong in both directions: notification permission granted while alarm permission was denied, and the app implying it could wake you.

They are now tracked separately, with the protocol stating plainly which is which:

protocol PermissionsProviding: Sendable {
    /// AlarmKit authorization, the capability that makes a *reliable* alarm possible.
    func alarmAuthState() async -> AuthState
    func requestAlarmAuth() async -> AuthState

    /// Notifications are supplementary (warnings, upcoming-alarm nudges), never the
    /// primary alarm.
    func notificationAuthState() async -> AuthState
    func requestNotificationAuth() async -> AuthState

    /// Whether the OS build supports AlarmKit at all.
    var supportsReliableAlarms: Bool { get }
}

Note the third member. There are three states, not two: the OS may not support AlarmKit, or it may support it and the user has not decided, or they have decided. Collapsing "unavailable" into "denied" gives the user a Settings link that leads nowhere useful.

An adjacent trap, since it cost us time: the time-sensitive interruption level for notifications comes from the entitlement, not from an authorisation option. The .timeSensitive option was deprecated in iOS 15, so requesting it in the options array does nothing.

Three smaller things that were not obvious

Keep every AlarmKit call behind one type. The framework is new, and signatures move. Ours are isolated in a single wrapper so the rest of the app is insulated, and there is exactly one file to audit against the SDK before a release.

Never store an absolute Date for a repeating alarm. We store a wall-clock time plus a set of weekdays and recompute the next fire date in the user's current calendar and time zone each time we schedule, then recompute again on significant-time-change and time-zone-change notifications. A "6:30 AM" alarm should be 6:30 AM across a DST transition, which a stored Date will not give you.

You do not get to run code at the moment the alarm fires. Anything that needs to happen afterwards — in our case a follow-up check a few minutes later — has to be scheduled ahead as its own alarm, not kept alive on a background timer. Once we accepted that, the follow-up became another AlarmKit alarm rather than something we tried to keep running.

What we would do differently

We would write the "does the system agree with us?" check before the status UI, not after. Almost everything in mistake 2 followed from building the badge first and the verification second, and every subsequent bug in that area was the same bug wearing a different hat.

We would also decide the no-fallback question on day one rather than in week three. It is an architectural choice, not a detail: it sets your deployment target, which sets your addressable users, which is a product decision as much as a technical one.

If you are adopting AlarmKit

  1. Decide early whether you ship a non-AlarmKit path at all. If you do, be explicit about what it does not guarantee.
  2. Model alarm authorisation and notification authorisation separately, with a third state for "unsupported".
  3. Verify scheduled alarms against the system before your UI claims anything.
  4. Make scheduling idempotent so re-checking is safe.
  5. Give every scheduling failure its own case and a fix action.
  6. Offset any backup alarm. Same-instant is a duplicate alert.
  7. Store recurrence as wall-clock plus weekdays, and recompute on time-zone and DST changes.
  8. Schedule follow-up work as its own alarm.
  9. Put every framework call behind one wrapper.

If you want the user-facing version of how this behaves in practice — whether an alarm rings on silent, what the permission prompt means — that is covered in how AlarmKit alarm apps work on iPhone.

We make Mornio, so we are not a neutral source on alarm apps. The implementation details above are from our own codebase and are offered as one worked example, not as the only way to use the framework.