Push notifications in Expo with Firebase and SST: architecture, timezone handling, and the decisions we made

Storyie Engineering Team
8 min read

How we designed diary reminder push notifications for Storyie — FCM token management, serverless Cron delivery via SST, timezone-aware scheduling, deduplication, and the trade-offs of running everything without a queue worker.

Storyie sends two kinds of push notifications: a daily diary reminder ("you haven't written yet today") and a content-limit warning when a free-plan user approaches their quota. Neither of these is rocket science in isolation, but the full picture — token lifecycle, timezone-aware Cron scheduling, deduplication, multi-locale copy, iOS/Android payload differences — has more surface area than it looks from the outside.

This post walks through what we built and why, including the places where we accepted explicit trade-offs.

TL;DR

  • Token management is client-driven; delivery logic is entirely server-side. The app registers and refreshes tokens; the server decides who gets what and when.
  • A 15-minute SST Cron job replaces a queue worker for time-based reminders. Simpler to operate, with an acceptable max-latency trade-off.
  • notification_logs with a period_key column is the deduplication primitive. It doubles as an operational reporting table.
  • Timezone handling requires rounding to a 15-minute window — exact-minute matching misses users because Lambda startup jitter is measured in seconds, not milliseconds.
  • FCM token cleanup is automatic: messaging/registration-token-not-registered → set is_active = false. No scheduled cleanup job needed.

Concern

Approach

Token storage

user_devices table, (user_id, fcm_token) unique index, logical delete

Reminder scheduling

SST Cron every 15 min, filter by user timezone + reminder time

Deduplication

notification_logs with (user_id, notification_type, period_key)

Token cleanup

Auto on registration-token-not-registered response from FCM

Content-limit trigger

Inline at write time, not Cron

Multi-locale copy

Per-locale message arrays with last-index tracking

Architecture overview

[Expo app]
  ├─ @react-native-firebase/messaging — token acquisition
  ├─ Supabase user_devices — upsert on each app open
  └─ NotificationContext — foreground / background / cold-start handling

[SST (AWS Lambda)]
  ├─ DiaryReminderNotifier: runs every 15 minutes
  ├─ Filter eligible users → Firebase Admin SDK → send
  └─ notification_logs — deduplication + audit trail

The guiding principle: token management is client-driven; delivery logic is entirely server-side. The app is responsible for registering tokens and rendering notifications. Everything about who gets a notification, when, and what it says lives on the server.

Device token management

Table design

// packages/database/src/schemas/devices.ts
export const userDevices = pgTable("user_devices", {
  id: uuid("id").primaryKey().default(sql`uuid_generate_v4()`),
  userId: uuid("user_id").notNull(),
  fcmToken: text("fcm_token").notNull(),
  platform: text("platform").notNull(),  // "ios" | "android"
  isActive: boolean("is_active").notNull().default(true),
  lastSeen: timestamp("last_seen", { withTimezone: true }),
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
  updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
}, (table) => ({
  userFcmUniqueIdx: uniqueIndex().on(table.userId, table.fcmToken),
}));

Three design decisions worth noting:

  • (user_id, fcm_token) unique index: calling registerDevice on every app launch is safe — repeated registrations with the same token hit the conflict path and update last_seen instead of inserting.
  • is_active as a logical delete: when FCM reports a token as invalid, we set is_active = false rather than deleting the row. If the user reinstalls and the same token is reissued, the next upsert flips it back to true.
  • platform column: iOS and Android need different payload shapes. Recording the platform at registration time means the sender doesn't have to guess.

Supabase RLS restricts each user to their own rows: user_id = auth.uid().

Client-side registration

// apps/expo/services/registerDevice.ts
export async function registerDevice() {
  const messagingInstance = getMessaging(getApp());
  const authStatus = await requestPermission(messagingInstance);

  const enabled =
    authStatus === AuthorizationStatus.AUTHORIZED ||
    authStatus === AuthorizationStatus.PROVISIONAL;
  if (!enabled) return;

  const fcmToken = await getToken(messagingInstance);
  if (!fcmToken) return;

  await supabase.from("user_devices").upsert(
    {
      user_id: user.id,
      fcm_token: fcmToken,
      platform: Platform.OS,
      is_active: true,
      last_seen: new Date().toISOString(),
    },
    { onConflict: "user_id,fcm_token" }
  );
}

upsert + onConflict makes this safe to call on every app open. last_seen getting updated on each launch is a bonus: it gives us a signal to identify devices that have gone silent.

Notification handling in the app

NotificationContext handles three app states, each with different behavior:

App state

Handling

Foreground

messaging().onMessage → show Alert

Background

messaging().onNotificationOpenedApp → navigate to relevant screen

Cold start (killed)

messaging().getInitialNotification → wait ~1 second, then navigate

The cold-start delay matters. Without it, navigation fires before the app's routing layer has initialized, and the deep link silently fails.

Badge clearing happens in two places: when the app moves to the foreground, and when a notification is tapped. Leaving a badge visible while the user is actively using the app is a small friction, but it adds up.

Server-side delivery

Why Cron instead of a queue

The typical architecture for time-based notifications is a job queue: when a user sets their reminder to 20:00, enqueue a job for that UTC timestamp; a worker processes it on time.

SST doesn't have a persistent worker process. Lambda runs on invocation. SQS + Lambda handles event-triggered work well, but "delay this message until a specific time in an arbitrary timezone" runs into SQS's 15-minute DelaySeconds ceiling immediately. EventBridge Scheduler can do it, but managing individual schedules per user at scale introduces operational complexity we weren't willing to take on.

So Storyie runs all background processing on Cron:

// sst.config.ts — background jobs
new sst.aws.Cron("DiaryReminderNotifier", { schedule: "cron(0/15 * * * ? *)" });
new sst.aws.Cron("EmailQueueProcessor",   { schedule: "cron(*/5 * * * ? *)" });
new sst.aws.Cron("ViewAggregator",        { schedule: "cron(0 */4 * * ? *)" });
new sst.aws.Cron("WeeklySummaryEmailSender", { schedule: "cron(0 12 ? * SUN *)" });

The cost is latency: reminders can be up to 15 minutes late, emails up to 5 minutes late. For a diary app, that's a reasonable trade-off. If Storyie were a real-time collaboration tool, it wouldn't be.

Diary reminder implementation

The reminder Cron runs every 15 minutes and works through a fixed pipeline:

  1. Fetch users with notification_settings.diaryReminderEnabled = true and active FCM tokens.
  2. For each user, convert the current UTC time to their local timezone and round to the nearest 15-minute mark. Check whether it matches their configured reminder time.
  3. Exclude users who have already written a diary entry today.
  4. Exclude users who already have a notification_logs record for today (period_key = YYYY-MM-DD).
  5. Send via Firebase Admin SDK.
  6. Write log records.

The rounding in step 2 is load-bearing. Lambda doesn't start at exactly :00 or :15 — it's a few seconds off. Exact-minute matching would miss users whose reminder is at 20:00 when the Lambda actually fires at 20:00:07. Rounding to the 15-minute grid makes the match tolerant of that jitter.

Deduplication

notification_logs tracks sent notifications with a (user_id, notification_type, period_key) composite key. The period_key format encodes the natural deduplication window:

Notification type

period_key format

Resets

Diary reminder

YYYY-MM-DD

Daily

Monthly content limit

YYYY-MM

Monthly

Annual content limit

YYYY-all

Never (until re-triggered)

This table also doubles as operational data. We can query it to see delivery volume, invalid token rate over time, and which notification types users actually interact with.

Multi-locale notification copy

User preferred language drives which message variant is selected:

const REMINDER_MESSAGES: Record<string, ReminderMessage[]> = {
  ja: [
    { title: "今日のひとこと 📝", body: "まだ何も書いてないよ。ひとこと残してみない?" },
    { title: "日記の時間だよ ✨", body: "今日あったこと、忘れないうちに書き留めよう。" },
    // ...
  ],
  en: [
    { title: "Time to Write! 📝", body: "You haven't written today..." },
    // ...
  ],
};

We also track the last message index per user so consecutive reminders don't repeat the same copy. A diary reminder that sounds like a broken record gets disabled immediately — and once a user disables notifications, they rarely turn them back on.

The tone is intentionally soft. Diary reminders live in a narrow emotional window: gentle enough to feel like a friendly nudge, not so aggressive that the user regrets enabling them.

Content-limit notifications

When a free-plan user hits 90% or 100% of their quota, we send a push notification. Unlike reminders, these are triggered inline at content creation time rather than by Cron — querying all free-plan users every 15 minutes to check their quota would be expensive and still introduce artificial latency.

Deduplication here uses a monthly period_key because diary quotas reset monthly. The 90% warning fires once per month; once the user upgrades or the month rolls over, it can fire again.

Automatic token cleanup

When Firebase returns messaging/registration-token-not-registered for a token, we update that row to is_active = false. No separate cleanup job, no TTL-based expiry — the feedback from FCM itself drives the state. If the user reinstalls and the same token appears again on registration, the upsert brings is_active back to true.

iOS and Android payload differences

FCM's multicast API accepts platform-specific overrides:

const message = {
  tokens,
  notification: { title, body },
  apns: {
    payload: {
      aps: {
        alert: { title, body },
        sound: "default",
        badge: 1,
      },
    },
  },
  android: {
    notification: {
      channelId: "high-priority",
      priority: "high",
    },
  },
};

Two things that are easy to miss:

  • iOS: sound: "default" must be explicit. Omitting it causes silent delivery even when the user has notifications enabled with sound.
  • Android: channelId is required on Android 8+. If the channel isn't registered on the device, the notification is silently dropped. priority: "high" ensures immediate delivery rather than batching when the app is backgrounded.

What we learned operating this

Timezone handling has more edge cases than you expect. The 15-minute rounding was a deliberate choice, not an oversight. Exact-minute matching causes missed deliveries that are hard to reproduce locally because they depend on Lambda cold-start timing.

"Not sending" is the hardest discipline. The logic for exclusions — written today, already notified today — is at least as important as the sending logic itself. Oversending is irreversible: users who turn off notifications almost never re-enable them.

Log tables earn their keep. We added notification_logs from the start because deduplication required it. The operational visibility it gives us — delivery rates, invalid token trends, which users are engaging — turned out to be just as valuable as the deduplication itself. Don't defer this table.

Related Posts

Try Storyie

If you want to see the notification system in action, enable diary reminders in the iOS app or at storyie.com. The reminder will arrive within 15 minutes of your configured time — intentionally quiet, and easy to turn off if you'd rather not.