Back

Notifications Without a Backend

Since we're using no backend, Stellar is excluded from all the cron jobs to send out notifications. Notifications are also extremely important to Stellar, since all the functions requires human's dedication to input their data, we need a system to remind them, otherwise the app would be sitting in the corner and eventually deleted.

This post is about how we built a full notification system on device only. The phone is the scheduler. SQLite is the data source and we rely on the OS to deliver the notifications.

If you have not read them yet, these posts cover the foundation this system sits on: How I Added AI to a Finance App With No Backend for the SQLite ledger, and Front End Engineering for how hooks and stores stay separate from saved data.


Overview

  • Mobile OS already let apps schedule local alarms. You register a future date and time. The OS fires a banner even when the app is closed. You do not need a backend for that. You need code that reads local data, decides what to schedule, and hands the OS a list of alarms.

  • A NotificationService that runs a daily check against SQLite, schedules alarms through expo-notifications, and mirrors delivered messages into an in-app bell feed. No push server in the loop.


What we did not build

Before diving into the architecture, it helps to know what we deliberately left out.

Most apps send reminders through a server. A computer in the cloud runs on a schedule, looks up your settings, and tells the phone to show a notification. That works well for many products. It did not fit ours.

What most apps doWhy we skipped it
Cloud schedulerLive up to the local , no cloud principle
Push from a remote serverThe reminder should work offline
Email or text remindersAdds another channel users have to manage. We wanted everything inside the app they already use.

Instead, we use local notifications. The app picks future dates and times, hands them to the phone's operating system, and the OS shows the banner — even when the app is closed. No server in the middle.

If you are building a similar local-first app, this is the core idea: the phone is the scheduler, the OS is the delivery system.


Two layers

Stellar runs two related systems. Works together but not the same thing

LayerWhat the user seesWhere it lives
OS notificationsLock screen banner, sound, alertexpo-notifications → iOS / Android
In-app feedBell icon, unread badge, history listZustand → AsyncStorage

When the OS delivers a banner, a listener copies it into the feed. Dismiss the banner and you still have history inside the app. Tap the banner and you land on the right screen through a deep link.


Architecture overview

Golden rule: NotificationService reads SQLite through injected repositories. No React inside the service layer. Scheduling logic you can unit test without mounting a component tree.

At app boot, _layout.tsx wires repos into the service:

tsx
notificationService.setRepositories({
  userRepository,
  incomeRepository,
  recurringRepository,
});

Then, if the user finished onboarding and left notifications enabled, the runtime activates: request permission, register a background task, run the first daily check.


The brain: runDailyCheck()

Everything flows through one orchestrator. Concurrent calls dedupe through a single in-flight promise.

ts
async runDailyCheck(): Promise<void> {
  await Promise.allSettled([
    this.scheduleWeeklyBatch(),      // income reminders, next 7 days
    this.checkRecurringExpenses(),   // bill reminders, 60-day window
    this.checkMissingDays(),         // immediate nudge if 2+ missed work days
    this.checkBackupReminder(),      // export cadence or overdue fire
  ]);
}

Each sub-check:

  1. Reads SQLite or AsyncStorage
  2. Decides what to schedule or fire now
  3. Calls Notifications.scheduleNotificationAsync() to register with the OS

When does it run?

TriggerWhat happens
App launch (returning user)Permission check + runDailyCheck()
Onboarding finishSame activation path
Background task (~12h minimum)Safety net when app has been closed
Save notification settingsReschedule with new prefs
Pull-to-refresh on feedManual resync
Log incomeCancels today's reminder only (see below)

Two scheduling modes

Mode A: Future DATE trigger

Pre-register an alarm with the OS. Fires even when the app is closed.

ts
await Notifications.scheduleNotificationAsync({
  content: {
    title: "Log today's income",
    body: 'Take 10 seconds to log today\'s income.',
    data: {
      category: 'income_reminder',
      deepLink: '/main/(tabs)/money/detail-entry',
      dateKey,
    },
  },
  trigger: { date: time, type: Notifications.SchedulableTriggerInputTypes.DATE },
});

Used for: income reminders, recurring bill reminders, future backup nudges.

Mode B: Immediate (trigger: null)

Fire now because the daily check found a condition that is already true.

ts
await Notifications.scheduleNotificationAsync({
  content: { title, body, data: { category, deepLink, ... } },
  trigger: null,
});

Used for: missing-day alerts, overdue backup reminders.

Immediate sends also add to the in-app feed inside sendNotification().


The four notification types

ts
export type NotificationCategory =
  | 'income_reminder'
  | 'missing_day'
  | 'recurring_expense'
  | 'backup_reminder';
CategoryWhen it firesSchedulingTap opens
income_reminderWork day, no income logged yetDATE at ~6 PM + offset, quiet hoursDetail Entry
missing_day2+ consecutive missed work daysImmediate during daily checkDetail Entry
recurring_expenseBill due within 60 daysDATE at dueDate - reminderDaysBeforeExpense screen
backup_reminderNo export within cadenceDATE or immediate if overdueSettings

Defaults: income, missing day, and recurring are on. Backup is off until the user enables it (Polaris subscribers get a sensible default).


Type 1: Income reminders

We need to respect user's privacy by not sending notifications on their off-days . This will be configurable by them in the settings.

Work schedule lives in SQLite as JSON on the user row (users.workSchedule: { mon: true, tue: true, ... }). The scheduler walks the next 7 days, skips non-work days, skips days where income is already logged, and registers one OS alarm per remaining day.

This was implemented as a pure functions in scheduler.ts, OS registration in notificationService.ts.

Reminder time formula:

baseTime = 6:00 PM + offset = user preference (0, 30, 60, or 120 minutes) clamped = quietHoursStart .. quietHoursEnd (default 8 AM to 9 PM)
ts
export function buildIncomeReminderTime(date: Date, preferences: NotificationPreferences): Date {
  const reminder = new Date(date);
  reminder.setHours(DEFAULT_REMINDER_HOUR, 0, 0, 0);
  reminder.setMinutes(reminder.getMinutes() + preferences.incomeReminderOffset);

  const clampedHour = clampHour(
    reminder.getHours(),
    preferences.quietHoursStart,
    preferences.quietHoursEnd
  );
  reminder.setHours(clampedHour, 0, 0, 0);
  return reminder;
}

Quiet hours note: These are an allowed window, not a "do not disturb block." Reminders get clamped into 8 AM to 9 PM, not rejected during it.

Smart cancel when you already logged

If you log income at 2 PM, a 6 PM nag would feel insulting. The income repository cancels today's scheduled alarm after a successful insert:

ts
const { cancelTodayReminder } = await import('../../services/notificationService');
await cancelTodayReminder(entry.date);

You did the work and Stellar cancels the alarm.


Type 2: Missing day alerts

A single missed day might mean a day off. Two consecutive work days with nothing logged is a pattern worth a gentle nudge.

During runDailyCheck(), the service walks backward up to 21 days. It counts consecutive work days (from schedule) with no row in income_entries. If the count hits 2, it fires an immediate notification. Max once per calendar day.

This is a condition check, not a pre-scheduled alarm. It only runs when runDailyCheck() runs.


Type 3: Recurring expense reminders

Users set up recurring bills (rent, booth fee, software) but still mark them paid manually. They asked for a heads-up before due date.

Each bill row in SQLite has reminderDaysBefore (default 3; set to 0 to disable per bill). The service queries bills due within 60 days, computes triggerDate = dueDate - reminderDaysBefore, and schedules a DATE alarm at quietHoursStart (default 8 AM).

ts
triggerDate.setDate(triggerDate.getDate() - reminderDaysBefore);
triggerDate.setHours(quietStart, 0, 0, 0);
if (triggerDate.getTime() <= Date.now()) return null;

Recurring bills do not auto-deduct from the ledger. The reminder only says "this bill is coming." Paying it still goes through markPaid().


Type 4: Backup reminders

Assuming users will forget to backup/export their data, we need a system to remind them.

lastBackupAt lives in AsyncStorage, not SQLite. Cadence is weekly (7 days), biweekly (14), or monthly (30). If the next reminder date is in the future, schedule a DATE trigger. If export is already overdue, fire immediately (rate limited to 2 sends per 24 hours so we do not spam).

After a successful export, exportService updates lastBackupAt and reschedules the next reminder.

Default: off. User opts in from Settings, or Polaris subscribers get it enabled by default.


Tracking OS notification IDs

The store remembers which OS alarm belongs to which logical reminder. When schedules change, we cancel stale IDs before registering new ones.

Store fieldKey formatPurpose
scheduledIncomeReminderIdsYYYY-MM-DDOne reminder per work day
scheduledRecurringReminderIdsrecurringId:dueDateOne per bill cycle
scheduledBackupReminderIdsingle IDNext backup nudge
ts
await Notifications.cancelScheduledNotificationAsync(notificationId);

Without tracking, you either cancel all scheduled notifications (bad) or leave orphan alarms that fire after the user changed settings.


Background task as safety net

Daily check runs on app launch. What if the user has not opened the app in three days?

We register STELLAR_DAILY_CHECK through expo-background-task:

ts
TaskManager.defineTask(TASK_NAME, async () => {
  await runDailyCheck();
  return BackgroundTask.BackgroundTaskResult.Success;
});

await BackgroundTask.registerTaskAsync(TASK_NAME, {
  minimumInterval: 60 * 12, // 12 hours minimum; OS decides actual timing
});

The task is defined in global scope so it exists before React mounts.

Trade-off: Background timing is OS-controlled. iOS may defer or skip under battery pressure. We accept "roughly twice a day" instead of "exactly at midnight." DATE triggers still fire on time for pre-scheduled income and bill reminders. The background task mainly catches missing-day checks and schedule drift.


Tap handling and deep links

When the user taps a banner:

ts
const responseSub = Notifications.addNotificationResponseReceivedListener((response) => {
  const route = handleNotificationOpen(response.notification);
  if (route) router.push(route as never);
});

handleNotificationOpen ingests into the feed, marks read, and resolves a route:

CategoryRoute
income_reminderDetail Entry
missing_dayDetail Entry
recurring_expenseExpense screen
backup_reminderSettings

Cold start from a tap uses Notifications.getLastNotificationResponse() after launch so the route still opens if the app was killed.


User preferences (all local)

Notification prefs live in Zustand, persisted to AsyncStorage. No server here.

SettingDefaultEffect
enabledtrueMaster kill switch
quietHoursStart / end8 / 21Clamps reminder times
incomeReminderOffset0 minShifts 6 PM base
categories.income_remindertrueWork-day nudges
categories.missing_daytrueConsecutive miss alerts
categories.recurring_expensetrueBill due reminders
categories.backup_reminderfalseExport nudges
backupReminder.cadencemonthlyweekly / biweekly / monthly

Work schedule itself is edited in Settings and saved to SQLite. Notification prefs are separate. Today, changing work schedule does not immediately reschedule alarms. The next daily check picks up the new days. Honest gap we would fix with a one-line call to runDailyCheck() after schedule save.


End-to-end story: income reminder for tomorrow

To visualize this flow:

No server anywhere in that chain.


We also have a widget

Separate systems. No shared code path.

SystemPackagePurpose
Home screen widgetexpo-widgetsSnapshot of net balance
Notificationsexpo-notificationsTime-based nudges

Widget sync listens to the same dataEvents bus as the rest of the app. Notifications listen to SQLite through runDailyCheck(). Do not conflate them.