Scheduled notifications — one of those tasks that seems simple until you hit platform limitations. On iOS — a limit of 64 active notifications, on Android — risk of reminders being lost after reboot. We've been building such systems for 7 years and know how to work around these pitfalls. In this article, we'll break down which technologies to choose and how to avoid typical mistakes.
Once a client asked to add daily reminders to a habit tracker. On the surface — a trivial task. But during testing on iOS, the app quickly hit the 64 notification limit, and on Android reminders stopped working after device reboot. We had to redesign the architecture: on iOS — dynamic identifier reuse, on Android — switching from AlarmManager to WorkManager. The experience cost a few sleepless nights, but the system became stable in the end.
When to choose what
Local notifications — if the time is tied to the user's device and doesn't change from the server. Habit tracker, medication reminder, event alarm.
Server-side scheduled — if centralized logic is needed: marketing campaigns by schedule, reminder about an event for all participants, deadline notification with user time zone consideration.
| Local | Server-side | |
|---|---|---|
| Network dependency | Not needed | Needed |
| Management | On device | On backend |
| Max notifications | 64 (iOS) / unlimited (Android) | Unlimited |
| Recurrence | Calendar/time triggers | Cron / task queue |
| Example | Reminder "stand up" every day at 9:00 | Campaign "offer ends in an hour" to all users |
How to choose between local and server-side notifications?
If the notification is tied to a user action (e.g., they set the reminder themselves) — go local. If the notification is server-initiated (new order, deadline, mass campaign) — go server-side. Hybrid approach: a local notification can be "synced" with the server via a push stub.
Server-side scheduling
OneSignal supports scheduled delivery directly in the API:
{
"app_id": "YOUR_APP_ID",
"include_aliases": { "external_id": ["user_44521"] },
"contents": { "en": "Team meeting in 15 minutes" },
"send_after": "YYYY-MM-DD HH:MM:SS UTC",
"delayed_option": "timezone",
"delivery_time_of_day": "9:00AM"
}
delayed_option: "timezone" — deliver at the specified time of day considering each recipient's time zone. Useful for "good morning" campaigns.
For custom backend — cron job or task queue via Celery/BullMQ:
// Node.js + BullMQ
const notificationQueue = new Queue('notifications', { connection: redis });
async function scheduleNotification(userId, content, sendAt) {
const delay = sendAt.getTime() - Date.now();
await notificationQueue.add(
'send_push',
{ userId, content },
{ delay, attempts: 3, backoff: { type: 'exponential', delay: 5000 } }
);
}
attempts: 3 with exponential backoff — a must. FCM sometimes returns 503, need retry.
Local scheduling on iOS
// Reminder every day at 8:00
func scheduleHabitReminder(habitId: String, name: String) {
let content = UNMutableNotificationContent()
content.title = name
content.body = "Don't forget to mark completion"
content.sound = .default
content.userInfo = ["habit_id": habitId]
var components = DateComponents()
components.hour = 8
components.minute = 0
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
let request = UNNotificationRequest(
identifier: "habit-\(habitId)",
content: content,
trigger: trigger
)
UNUserNotificationCenter.current().add(request) { error in
if let error { print("Schedule failed: \(error)") }
}
}
// Change reminder time (remove old, add new)
func rescheduleReminder(habitId: String, newHour: Int, newMinute: Int) {
UNUserNotificationCenter.current()
.removePendingNotificationRequests(withIdentifiers: ["habit-\(habitId)"])
// ... create new request with updated components
}
Apple recommends not exceeding 64 scheduled notifications per app (UNUserNotificationCenter).
Why WorkManager is preferable to AlarmManager on Android?
AlarmManager is precise but doesn't survive reboot. WorkManager survives reboot but execution time is approximate (±15 minutes on Android 12+ due to Doze). Choice depends on precision requirements. For reminders where precision isn't critical (e.g., "drink water"), WorkManager is a reliable solution.
// WorkManager — for non‑critical time reminders
fun scheduleHabitReminder(habitId: String, reminderHour: Int, reminderMinute: Int) {
// Calculate delay until next firing
val now = Calendar.getInstance()
val target = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, reminderHour)
set(Calendar.MINUTE, reminderMinute)
set(Calendar.SECOND, 0)
if (before(now)) add(Calendar.DAY_OF_YEAR, 1)
}
val delayMs = target.timeInMillis - now.timeInMillis
val workRequest = OneTimeWorkRequestBuilder<ReminderWorker>()
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
.setInputData(workDataOf(
"habit_id" to habitId,
"next_reminder_hour" to reminderHour,
"next_reminder_minute" to reminderMinute
))
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork("habit-$habitId", ExistingWorkPolicy.REPLACE, workRequest)
}
// In Worker — show notification and schedule next
class ReminderWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
override fun doWork(): Result {
val habitId = inputData.getString("habit_id") ?: return Result.failure()
showNotification(habitId)
// Schedule next day
scheduleHabitReminder(
habitId,
inputData.getInt("next_reminder_hour", 8),
inputData.getInt("next_reminder_minute", 0)
)
return Result.success()
}
}
ExistingWorkPolicy.REPLACE — if user changes reminder time, old task is replaced with new one.
Managing reminders in UI
The user should see scheduled reminders and be able to manage them. On iOS:
// Load all pending reminders
func loadPendingReminders() async -> [ScheduledReminder] {
return await withCheckedContinuation { continuation in
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
let reminders = requests.compactMap { ScheduledReminder(from: $0) }
continuation.resume(returning: reminders)
}
}
}
Display in a list with ability to edit time or delete. On delete — removePendingNotificationRequests + remove from WorkManager (Android).
Typical mistakes and how to avoid them
Checklist: what to check before deployment
- Ensure iOS does not exceed 64 notification limit. Reuse identifiers.
- On Android, test after device reboot.
- For repeating notifications, use WorkManager with rescheduling inside Worker.
- Test in Doze mode (Android) and Low Power Mode (iOS).
- Ensure time zone is handled correctly.
What the work includes
Turnkey development of scheduled notifications includes:
- Notification architecture design (local/server/hybrid)
- Scheduler implementation on iOS (UNUserNotificationCenter) and Android (WorkManager or AlarmManager)
- Server queue integration (BullMQ, Celery) or OneSignal
- UI for schedule management (list, add, edit, delete)
- Testing on real devices: Doze mode, reboot, time zone change
- Documentation on access and operation
We guarantee stable notification operation — proven on 50+ projects. The cost of turnkey development depends on complexity and is determined after analysis. Contact us to pinpoint your case and get an estimate.
Timeline
Implementation of scheduled notifications with UI schedule management, reboot support on Android, and 64‑notification limit handling on iOS — 3–5 working days. If server-side scheduling via OneSignal or custom queue is added — another 2–3 days. Get a consultation and accurate estimate for your project — reach out to us.







