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_logswith aperiod_keycolumn 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→ setis_active = false. No scheduled cleanup job needed.
Concern | Approach |
|---|---|
Token storage |
|
Reminder scheduling | SST Cron every 15 min, filter by user timezone + reminder time |
Deduplication |
|
Token cleanup | Auto on |
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 trailThe 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: callingregisterDeviceon every app launch is safe — repeated registrations with the same token hit the conflict path and updatelast_seeninstead of inserting.is_activeas a logical delete: when FCM reports a token as invalid, we setis_active = falserather than deleting the row. If the user reinstalls and the same token is reissued, the nextupsertflips it back totrue.platformcolumn: 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 |
|
Background |
|
Cold start (killed) |
|
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:
- Fetch users with
notification_settings.diaryReminderEnabled = trueand active FCM tokens. - 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.
- Exclude users who have already written a diary entry today.
- Exclude users who already have a
notification_logsrecord for today (period_key = YYYY-MM-DD). - Send via Firebase Admin SDK.
- 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 |
| Resets |
|---|---|---|
Diary reminder |
| Daily |
Monthly content limit |
| Monthly |
Annual content limit |
| 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:
channelIdis 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
- Building a Monorepo with pnpm and TypeScript — workspace structure and package conventions that the jobs package lives within
- Building a Cross-Platform Mobile App with Expo — broader Expo architecture context
- Cross-platform Lexical with
use dom: monorepo gains and the bridges you still own — another deep-dive into a feature that spans the Expo/web divide
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.