Membangun Sistem Notifikasi Push yang Andal dan Sadar Zona Waktu
Sebuah aplikasi kesehatan & kebugaran perlu mengirimkan dorongan harian yang dipersonalisasi — pengingat makan, pemeriksaan suasana hati, pengingat hidrasi, isyarat tidur, dan pengingat khusus — kepada ribuan pengguna di seluruh dunia. Intinya: setiap notifikasi harus tiba pada waktu lokal yang tepat, tepat satu kali, dan tidak pernah ke perangkat yang mati. Kami merancang dan membangun pipeline terdistribusi yang mewujudkan hal tersebut.
Tantangan
- Kebenaran zona waktu dalam skala besar. "Pengingat sarapan jam 9 pagi" memiliki arti yang berbeda bagi setiap pengguna. Mengirimkan pada waktu server akan memicu seseorang di Sydney pada jam 3 pagi. Setiap notifikasi perlu diselesaikan ke momen lokal pengguna.
- Tidak ada spam, tidak ada duplikasi. Penjadwalan crons pasti tumpang tindih dan berjalan kembali. Tanpa jaminan ketat, satu pengguna dapat menerima "saatnya makan siang 🥗" yang sama dua atau tiga kali — jalur cepat menuju penghapusan instalasi.
- Perangkat tidak dapat diandalkan. Pengguna menghapus instalasi aplikasi, mencabut izin, dan merotasi push token secara terus-menerus. Mengirimkan notifikasi secara membabi buta ke token usang membuang sumber daya dan merusak metrik pengiriman.
- Pengaturan waktu yang tepat tanpa penjadwal brute-force. Mengirimkan ratusan notifikasi pada menit yang tepat — tanpa cron yang membebani database setiap 60 detik — membutuhkan mekanisme yang lebih cerdas daripada polling sederhana.
Solusi Kami
Kami membangun pipeline tiga tahap yang secara jelas memisahkan apa yang akan dikirim, kapan akan dikirim, dan benar-benar mengirimkannya — sehingga setiap tahap dapat gagal dan pulih secara independen. Database adalah sumber kebenaran, antrean pesan menangani pengaturan waktu yang tepat, dan lapisan worker tunggal berkomunikasi dengan penyedia push.

Arsitektur
- Expo-notifications adalah klien React Native dengan native channel, unique noise, dan deep link yang menggunakan format token tunggal dan API pengiriman untuk iOS dan Android.
- Backend NestJS dengan expo-server-sdk sebagai abstraksi push terpadu di atas FCM dan APNs.
- MongoDB sebagai sumber kebenaran — koleksi NotificationMessage, NotificationToken, dan NotificationCounter.
- ActiveMQ (STOMP) delay queue, satu per kategori (makan, suasana hati, aktivitas, keamanan, pengingat), untuk pengiriman terjadwal yang tepat.
- Creator crons yang menghasilkan catatan notifikasi yang diselesaikan berdasarkan zona waktu per pengguna.
- Consumer worker yang berlangganan setiap queue dan melakukan validasi akhir sebelum mengirim.
- AWS ECS Fargate menjalankan crons dan consumer; ActiveMQ pada instans EC2 khusus.
Fitur Utama
- Penjadwalan yang sadar zona waktu. Menggunakan date-fns-tz, waktu pengiriman lokal setiap pengguna dihitung, dikonversi kembali ke UTC untuk penyimpanan, dan dibatasi oleh jendela tanggal UTC untuk menjamin satu dorongan per hari.
- Idempotensi yang diberlakukan database. Indeks unik parsial pada pesan yang tertunda membuat pembuatan duplikat tidak mungkin — bahkan ketika cron berjalan dua kali:
| // Unique only while the message is still PENDING and not deleted schema.index( { userId: 1, notificationTokenId: 1, category: 1, label: 1, scheduledAt: 1 }, { unique: true, partialFilterExpression: { status: 'pending', isDeleted: false } } ); |
3. Pengiriman yang tepat melalui antrean tunda (delay queues). Alih-alih cron per menit, penjadwal mengantrekan pesan sekarang tetapi menunda pengiriman hingga menit yang tepat menggunakan header scheduled-delay ActiveMQ:
| client.send(`/queue/${queueName}`, { persistent: 'true', 'AMQ_SCHEDULED_DELAY': String(delayMs), // delivered exactly when due }, JSON.stringify(message)); |
4. Satu token aktif per perangkat. Indeks unik parsial menjamin tepat satu token aktif per perangkat; login baru secara bersih menonaktifkan token lama, dengan percobaan ulang exponential-backoff untuk bertahan dari login bersamaan.
5. Pemeriksaan resi + pembersihan otomatis. Setelah pengiriman, kami melakukan polling resi Expo. Respons DeviceNotRegistered segera menonaktifkan token yang mati sehingga kami tidak akan pernah lagi membuang pengiriman ke token tersebut.
| f (receipt.status === 'error' && receipt.details?.error === 'DeviceNotRegistered') { await this.deactivateToken(token); // stop sending to dead devices } |
6. Batas kegagalan. Setiap perangkat memiliki penghitung percobaan ulang; setelah 3 kegagalan berturut-turut, token secara otomatis dinonaktifkan — tidak ada loop tak terbatas, tidak ada token zombie.
7. Menghormati niat pengguna pada saat pengiriman. Preferensi notifikasi diperiksa ulang oleh consumer pada saat pengiriman, tidak hanya pada saat penjadwalan — sehingga pengguna yang memilih keluar satu jam sebelum dorongan tidak akan pernah menerimanya. Pesan diselesaikan ke status akhir yang jujur: success, failed, atau is_missed.
Hasil
- Setiap pengguna menerima dorongan pada waktu lokal yang benar, di seluruh dunia — nol notifikasi di luar jam kerja.
- Notifikasi duplikat dihilangkan sepenuhnya melalui idempotensi tingkat database.
- Token perangkat yang mati dan usang terdeteksi dan dinonaktifkan secara otomatis, menjaga pengiriman tetap bersih.
- Keandalan meningkat dan beban database berkurang dengan pengiriman yang tepat dan akurat per menit tanpa penjadwal brute-force.
Tumpukan Teknologi
React Native · Expo Notifications · NestJS · TypeScript · MongoDB · ActiveMQ (STOMP) · expo-server-sdk · date-fns-tz · AWS ECS Fargate

