A soft warm card with a cake emoji
← Ghi chú·Engineering

How We Built Birthday Reminders (Without Being Creepy)

V
Van Thuong Dao·May 5, 2026·4 min read

When we sat down to spec birthday reminders, the first thing we threw out was the obvious approach: ask the user's OS to fire a local notification on the right date. It works, but it's fragile — depends on the device being on, the app being installed, the user not having cleared notification permissions. And it scales badly to multi-device users.

Instead we put the logic on the server. One cron job, runs once a day, queries who has a birthday, and pushes APNs notifications to everyone in their circle.

The cron

// NestJS @nestjs/schedule
@Cron('0 9 * * *', { name: 'birthday-reminders' })
async sendDailyBirthdayReminders(): Promise<void> {
  this.logger.log('🎂 Running daily birthday reminders cron');
  const sent = await this.runReminders();
  this.logger.log(`🎂 Done — ${sent} push(es) sent`);
}

Pattern 0 9 * * * = 9:00 every day, server timezone. Default Docker is UTC. For a Vietnam-based service, we set TZ=Asia/Ho_Chi_Minh in docker-compose.yml so it fires at 9am Hanoi time, not 4pm.

The query

MongoDB aggregation operators $month and $dayOfMonth let us match by MM-DD without storing month/day separately:

const now = new Date();
const month = now.getMonth() + 1;
const day = now.getDate();

const birthdayUsers = await this.userModel.find({
  birthday: { $exists: true, $ne: null },
  $expr: {
    $and: [
      { $eq: [{ $month: '$birthday' }, month] },
      { $eq: [{ $dayOfMonth: '$birthday' }, day] },
    ],
  },
  // Privacy filter — opt-out users not surfaced
  $or: [
    { 'privacySettings.shareBirthday': { $exists: false } },
    { 'privacySettings.shareBirthday': 'contacts' },
  ],
}).select('_id displayName').lean();

The privacy filter is the important part. Users can opt out of sharing their birthday in PrivacySettingsView. We honor that at the query level — they're excluded entirely, not just masked at the response layer. No one gets a push about their birthday. They become invisible.

Fan-out

For each user with a birthday today, we query the reverse contact graph — who has added them as a contact — and push to all those owners:

for (const user of birthdayUsers) {
  const contacts = await this.contactModel
    .find({ contact: user._id })
    .select('owner')
    .lean();

  if (contacts.length === 0) continue;

  const ownerIds = contacts.map((c) => c.owner.toString());
  await this.apns.sendCustomPush(
    ownerIds,
    'Sinh nhật người thân',
    `🎂 Hôm nay là sinh nhật của ${user.displayName}`,
    { type: 'birthday', userId: user._id.toString() },
    new Set(),  // empty — push everyone, online or not
  );
}

Deep link routing

The push payload includes type: 'birthday'so the iOS client knows to open the contact's profile, not start a chat. In NotificationService:

// iOS
switch userInfo["type"] as? String {
case "birthday":
  if let userId = userInfo["userId"] as? String {
    NotificationCenter.default.post(
      name: .openContact,
      userInfo: ["userId": userId]
    )
  }
default:
  // legacy / new_message — open chat
  ...
}

The user lands on the contact's profile screen. From there they can voice-call, video-call, or tap "message" to send a wish. We don't auto-compose a template message — the whole point of an intimate app is that the wish is yours.

What we deliberately didn't build

  • Year-of-birth display. We store the birthday Date but only render month/day. Age is none of the receiver's business unless the person tells them.
  • Birthday countdown stories. No 7-day countdown stream. Just one push, on the day. Anything more is platform performance theater.
  • Public birthday board. No tab listing "upcoming birthdays this week." The push is enough. We don't need to make a feature spectacle out of it.
The cron is 30 lines of code. The interesting design work was in not building the obvious surrounding features. Restraint as a design discipline.

Build target: TestFlight. We'll watch open rates and tap-throughs to see if "one push, one day" is enough, or if we need to soften it further (e.g. opt-in per contact). For now, the simplest thing that could possibly work.