Skip to content

B2C mobile notifications — local reminders, streaks, and push

Status: in-progress LNO: L — establishes the re-engagement channel for the B2C mobile app. Splits notifications into a free, offline, device-scheduled path (reminders, streaks) and a server-originated push path (generation done, announcements), so the retention loop does not depend on paid push infrastructure. Owners: @bhanu Last updated: 2026-07-13 Roles affected: B2C learners on the Android app (apps/mobile-b2c). B2B and web unaffected. Primary surfaces: (app)/notifications, (app)/settings/notifications, OS notification shade, in-app banner Source of the ask: self-initiated, @bhanu (co-owner), 2026-07-13. Prompt: "check if there are any push notifications for the native mobile app — I don't think we have any. What is the strategy?" Related: Study Plan, Practice Hub Engine, Personalized Learner Journey

TL;DR

The mobile app shipped a notifications feed screen, unread badge, and settings toggles — all wired to a real backend — but nothing ever produced a notification for a B2C learner, so the feed was permanently empty, and there was no push and no pop-up of any kind. Worse, the toggles were cosmetic (never persisted) and the backend's unread-count/mark-read queries were silently compiling to WHERE false.

The fix splits delivery by who originates the event:

Trigger Mechanism Server push needed? Cost
Daily study-plan reminder Local scheduled notification (on-device) No Free everywhere
Streak / inactivity nudge Local scheduled, re-armed on foreground No Free everywhere
AI generation complete Remote push (Expo Push -> FCM) Yes Free on Android
Product announcement Remote push broadcast Yes Free on Android

The key insight: reminders and streaks are not push. They are deterministic and time-based, so the device schedules them itself. That covers the entire retention loop with zero server infrastructure, zero cost, and works offline. Remote push is reserved for events only the server knows about.

Why this exists

Who asked for it

Self-initiated by @bhanu, 2026-07-13. Not a user-reported ticket and not a support trend. Honest flag: Q1 (who asked) and Q4 (validated) are weak — this is a product hypothesis, not a validated user request.

What user pain does it solve

A self-study learner sets up a study plan, closes the app, and nothing ever brings them back. There is no reminder, no streak reinforcement, and no "your deep research is ready" when a long AI job finishes while the app is backgrounded.

Compounding it, the app looked like it had notifications: a bell-shaped feed screen, an unread badge on the Profile tab, and email/push toggles in settings. All of it was dead. A learner who opened that screen found an empty list, and a learner who flipped a toggle changed nothing. Dead UI reads as broken, not as absent.

Cost of not doing it

  • Retention bleed. For an exam-prep learner, "forgot to come back" is churn. The app has no mechanism to interrupt that.
  • A visibly broken surface. An always-empty feed and non-functional toggles erode trust in the parts of the app that do work.
  • Silent backend rot. The unread-count and mark-read endpoints were returning zero rows for every user (see Fixes below); had a producer ever been added, the feature still would not have worked.

Validation

Guess, corroborated by category precedent. No user interview behind it. Reminder/streak-driven retention is well established in consumer learning (Duolingo is the canonical case), but it is not validated for Kwilo specifically, and reminder-led engagement can annoy as easily as it retains.

Risk is deliberately asymmetric by trigger:

  • Generation-complete and announcements are low-risk utility — the user asked for the thing; we are telling them it is ready.
  • Reminders and streak nudges are the nagging triggers and carry real annoyance risk. They are opt-in-shaped, per-category, and cancellable.

Treat reminder-driven retention as the open question to measure post-launch.

How we know it worked

User outcomes, not delivery counts:

  • D7 retention lifts for learners with an active study plan and reminders enabled, versus those with reminders off.
  • Notification -> session click-through, not just send volume.
  • Reminder opt-out rate stays low. A high opt-out rate is the signal that we are nagging, and it should override any retention gain.
  • Sean Ellis counterfactual: remove reminders a week after launch — would a plan-following learner notice and complain with specifics? For an engaged learner mid-plan, yes. For everyone else, honestly, no, and that is the number to watch.

How it works

Delivery split

Local scheduled notifications (expo-notifications) handle reminders and streak nudges. The OS fires them on-device at the scheduled time, with the app closed, offline, with no server involved. They are re-armed whenever the app foregrounds, and they carry a deep-link route in their payload.

Remote push handles server-originated events. The device registers an Expo push token after login; the backend stores it, and when a Notification row is created it also dispatches a push to that user's devices (gated on their per-category preference).

Both paths converge on the same tap handler and the same in-app banner, so a local reminder and a remote push behave identically from the learner's point of view.

Foreground vs background

  • App backgrounded or killed -> the OS notification shade, as normal.
  • App in the foreground -> the OS heads-up is suppressed and an in-app banner slides in instead, so a notification never covers the thing the learner is actively doing.
  • Tap, from any state (including a cold launch from a killed app) -> deep-links to the notification's route.

Preferences

Four independent categories: study reminders, streak nudges, announcements, generation complete, plus a chosen reminder time. Preferences persist on-device and sync to the backend. A local edit made offline is never clobbered by a stale server value — it is held as pending and replayed on reconnect.

Enabling a locally-scheduled category triggers the notification-permission ask in context, not on cold launch.

Backend

  • user_devices — Expo push-token registry (one row per device, pruned automatically when Expo reports a token as dead).
  • notification_preferences — per-user, per-category, with the reminder time.
  • Push dispatch fires on commit, not on row creation, so a notification whose transaction rolls back never produces a push.
  • Announcement broadcast fans out through one device-token query and one chunked send, rather than one task per recipient.

Fixes folded in

The existing notification API was querying not Notification.is_read, which Python evaluates at query-build time to a plain False — so every unread-count, mark-read, and mark-all-read query compiled to WHERE false. Unread counts were always zero and nothing was ever markable as read. Corrected to is_read.is_(False). The feed could not have worked without this regardless of what produced the notifications.

Cost and platform reality

  • Android push is free. FCM is free and unlimited; the Expo Push Service on top of it is free. There is no per-message cost.
  • Local notifications are free on every platform and need no push credentials at all.
  • iOS is deferred. It needs an Apple Developer Program membership (99 USD/yr) for APNs — which is required to ship to the App Store anyway. No code rework: the same pipeline plus an APNs key.
  • EAS build quota is the real constraint. Native builds consume the Expo plan's monthly Android build allowance. Adding expo-notifications moves the native fingerprint, so this feature cannot ship over-the-air — it needs a native build, and that build cannot be cut while the quota is exhausted. Builds are now manual (Actions -> Mobile B2C EAS -> Run workflow) precisely because auto-building every PR drained the quota.

Non-goals

  • Marketing / promotional blasts. Announcements are for product news, low frequency, and are pref-gated.
  • Rich media (images, big text) in push payloads.
  • Web push for apps/web.
  • A quiet-hours engine. Deferred until we see whether reminders actually annoy; the per-category opt-out is the v1 escape hatch.
  • Per-notification granularity beyond the four categories.

Rejected alternatives

  • Use remote push for reminders too. Rejected. Reminders are deterministic and time-based; local scheduling is free, works offline, and avoids standing up a server-side cron plus a device registry just to say "time to study". It also keeps reminders working when the backend is down.
  • A persistent WebSocket for in-app notifications. Deferred. The backend already has a WebSocket manager, but a always-on socket costs battery and complexity. The existing 60s poll plus local delivery covers the foreground case.
  • A third-party platform (OneSignal, Braze). Rejected for v1. Expo Push plus local scheduling covers every trigger we have, for free. Revisit only when we need real segmentation and campaign tooling, which is a marketing need we do not have yet.
  • Ship push before wiring any producer. Rejected, and this was the original trap: the app already had the feed UI with no producers behind it. Push infrastructure that delivers an empty feed is worse than no push, because it advertises a promise the product does not keep.