Construire un système de notification push fiable et sensible aux fuseaux horaires
Une application de santé et bien-être devait envoyer des rappels quotidiens personnalisés — rappels de repas, suivis d'humeur, invites à s'hydrater, indices de sommeil et rappels personnalisés — à des milliers d'utilisateurs à travers le monde. Le défi : chaque notification devait arriver à la bonne heure locale, exactement une fois, et jamais sur un appareil inactif. Nous avons conçu et construit le pipeline distribué qui permet cela.
Le Défi
- Exactitude du fuseau horaire à l'échelle. Un "rappel de petit-déjeuner à 9h" signifie quelque chose de différent pour chaque utilisateur. L'envoi à l'heure du serveur aurait notifié quelqu'un à Sydney à 3h du matin. Chaque notification devait être résolue au moment local de l'utilisateur.
- Pas de spam, pas de doublons. Les planifications de crons se chevauchent et se réexécutent inévitablement. Sans garanties strictes, un seul utilisateur pourrait recevoir le même rappel "l'heure du déjeuner 🥗" deux ou trois fois — un chemin rapide vers une désinstallation.
- Les appareils ne sont pas fiables. Les utilisateurs désinstallent des applications, révoquent des permissions et changent leurs push tokens constamment. Envoyer des notifications aveuglément à des tokens obsolètes gaspille des ressources et corrompt les métriques de livraison.
- Synchronisation précise sans ordonnanceur par force brute. Délivrer des centaines de notifications à des minutes exactes — sans qu'un cron ne martèle la base de données toutes les 60 secondes — a nécessité un mécanisme plus intelligent que le polling naïf.
Notre Solution
Nous avons construit un pipeline en trois étapes qui sépare clairement ce qu'il faut envoyer, quand l'envoyer et l'envoyer réellement — afin que chaque étape puisse échouer et se rétablir indépendamment. La base de données est la source de vérité, une file de messages gère la synchronisation précise, et une couche de workers unique communique avec le push provider.

Architecture
- Expo-notifications est un client React Native avec des canaux natifs, des sons uniques et des liens profonds qui utilise un format de token unique et une API de livraison pour iOS et Android.
- Backend NestJS avec expo-server-sdk comme abstraction de push unifiée sur FCM et APNs.
- MongoDB comme source de vérité — les collections NotificationMessage, NotificationToken, et NotificationCounter.
- Files d'attente à délai ActiveMQ (STOMP), une par catégorie (repas, humeur, activité, sécurité, rappels), pour une livraison planifiée précise.
- Crons créateurs qui génèrent des enregistrements de notification résolus par fuseau horaire par utilisateur.
- Workers consommateurs qui s'abonnent Ă chaque file d'attente et effectuent la validation finale avant l'envoi.
- AWS ECS Fargate exécutant les crons et les consommateurs ; ActiveMQ sur une instance EC2 dédiée.
Fonctionnalités Clés
- Planification sensible aux fuseaux horaires. En utilisant date-fns-tz, l'heure d'envoi locale de chaque utilisateur est calculée, reconvertie en UTC pour le stockage, et délimitée par des fenêtres de dates UTC pour garantir un rappel par jour.
- Idempotence appliquée par la base de données. Un index unique partiel sur les messages en attente rend la création de doublons impossible — même si un cron s'exécute deux fois:
| // Unique uniquement tant que le message est PENDING et non supprimé schema.index( { userId: 1, notificationTokenId: 1, category: 1, label: 1, scheduledAt: 1 }, { unique: true, partialFilterExpression: { status: 'pending', isDeleted: false } } ); |
3. Livraison précise via les files d'attente à délai. Au lieu d'un cron à la minute, l'ordonnanceur met les messages en file d'attente maintenant mais diffère la livraison à la minute exacte due en utilisant l'en-tête scheduled-delay d'ActiveMQ :
| client.send(`/queue/${queueName}`, { persistent: 'true', 'AMQ_SCHEDULED_DELAY': String(delayMs), // livré exactement à l'heure prévue }, JSON.stringify(message)); |
4. Un seul token actif par appareil. Un index unique partiel garantit exactement un token actif par appareil ; les nouvelles connexions retirent proprement l'ancien token, avec des tentatives de réessai à recul exponentiel pour survivre aux connexions concurrentes.
5. Vérification des reçus + nettoyage automatique. Après l'envoi, nous interrogeons les reçus Expo. Une réponse DeviceNotRegistered désactive immédiatement le token inactif afin que nous ne gaspillions plus jamais un envoi dessus.
| f (receipt.status === 'error' && receipt.details?.error === 'DeviceNotRegistered') { await this.deactivateToken(token); // arrĂŞter l'envoi aux appareils inactifs } |
6. Plafond d'échecs. Chaque appareil dispose d'un compteur de tentatives ; après 3 échecs consécutifs, le token est automatiquement retiré — pas de boucles infinies, pas de tokens zombies.
7. Respect de l'intention de l'utilisateur au moment de l'envoi. La préférence de notification est revérifiée par le consommateur à la livraison, et pas seulement à la planification — ainsi, un utilisateur qui se désabonne une heure avant un rappel ne le reçoit jamais. Les messages se résolvent en des états terminaux honnêtes : success, failed, ou is_missed.
Résultats
- Chaque utilisateur reçoit des rappels à l'heure locale correcte, partout dans le monde — zéro notification en dehors des heures.
- Notifications en doublon entièrement éliminées grâce à l'idempotence au niveau de la base de données.
- Les tokens d'appareil inactifs et obsolètes sont détectés et retirés automatiquement, maintenant une livraison propre.
- La fiabilité est augmentée et la charge de la base de données est réduite avec une livraison précise, à la minute près, sans ordonnanceur par force brute.
Pile Technologique
React Native · Expo Notifications · NestJS · TypeScript · MongoDB · ActiveMQ (STOMP) · expo-server-sdk · date-fns-tz · AWS ECS Fargate

