# Envoyer 100 000 push notifications sans Pushwoosh — une Cloud Function Firebase et du parallélisme > Comment on a construit une Cloud Function Firebase pour envoyer des push notifications en masse en contournant les limites de Pushwoosh — batches de 499 tokens, parallélisme, randomisation de l'ordre d'envoi, et les joies de la documentation Firebase qui ment. Date : 20/02/2023 Auteur : Aurélien N. Tags : Firebase, TypeScript, Cloud Functions, Push Notifications, Architecture --- On a livré la [réécriture de l'app chocobonplan](/blog/chocobonplan-rewrite-react-native-architecture-rematch) il y a quelques mois. L'app est en production, le Black Friday s'est bien passé, les utilisateurs sont contents. Mais un problème récurrent remonte : les push notifications arrivent en retard pendant les pics de trafic. chocobonplan envoie des notifications à des dizaines de milliers d'utilisateurs quand un bon plan chaud est publié. Ventes flash, codes promo à durée limitée, baisses de prix exceptionnelles — chaque seconde compte. Le service utilisé pour l'envoi, Pushwoosh, facture au volume et commence à saturer pendant les ventes flash. Les notifications partent par vagues, certains utilisateurs les reçoivent 15 minutes après la publication. Pour un bon plan qui expire dans 30 minutes, c'est la moitié du temps utile perdu. L'idée : court-circuiter Pushwoosh pour les envois massifs et appeler directement Firebase Cloud Messaging (FCM), le service de push de Google. L'app utilise déjà Firebase pour le crash reporting — les tokens FCM sont déjà dans notre base. On construit une Cloud Function qui reçoit une liste de tokens et un message, et qui les envoie en parallèle via l'API `sendMulticast` de Firebase Admin. C'est un POC. On va le comparer avec Pushwoosh sur un mois de trafic réel et décider si on bascule définitivement. ## L'architecture : une seule fonction, bien faite Le projet tient en quatre fichiers TypeScript. C'est volontairement minimal — on veut un service stateless, déployé en `europe-west1`, qui fait une seule chose et la fait bien. ``` functions/src/ ├── index.ts ← point d'entrée ├── config.ts ← configuration validée └── https/ ├── sendBulkPushNotifications.ts ← la fonction └── utils.ts ← auth, validation, CORS ``` L'endpoint est un `POST` HTTP protégé par une clé API dans le header `X-Api-Key`. Le body contient un `message`, un tableau de `tokens` FCM, et des options (titre, image, TTL, priorité, configuration spécifique iOS/Android). La réponse détaille le nombre de succès, d'échecs, et les tokens qui ont échoué. On a documenté l'API avec une spec OpenAPI 3.0 complète — chaque champ, chaque erreur, chaque code de retour. Le backend du client peut appeler la fonction en toute confiance : si le body est malformé, il reçoit un 422 avec le détail de l'erreur de validation. ## 499, pas 500 La documentation Firebase indique que `sendMulticast` accepte jusqu'à **500 tokens** par appel. C'est faux. Ou plutôt, c'est vrai en théorie, mais en pratique, à 500 tokens, on observe des timeouts intermittents sur la Cloud Function. À 499, jamais. On ne sait pas exactement pourquoi. Notre hypothèse : le SDK Firebase Admin ajoute un overhead par token (sérialisation, validation interne), et à 500 tokens exactement, le temps de traitement dépasse le seuil interne de timeout de l'appel gRPC vers FCM. Quoi qu'il en soit, 499 est notre limite, validée par le schéma Joi : ```typescript const sendBulkPushNotificationsSchema = Joi.object().required() ``` ## Le parallélisme côté appelant La Cloud Function traite un batch de 499 tokens maximum. Pour envoyer à 100 000 utilisateurs, le backend du client doit : 1. **Récupérer** tous les tokens FCM actifs depuis la base 2. **Mélanger** la liste aléatoirement (on y revient) 3. **Découper** en chunks de 499 4. **Envoyer** tous les chunks en parallèle Le point 4 est critique. Si on envoie les chunks séquentiellement, 100 000 tokens / 499 = ~200 appels × ~2 secondes chacun = ~7 minutes. En parallèle, avec Firebase qui scale automatiquement les instances de Cloud Functions, les 200 appels partent simultanément et se terminent en ~10 secondes. Firebase auto-scale les instances de Cloud Functions : chaque requête HTTP entrante peut être servie par une nouvelle instance. Pas besoin de configurer de la concurrence — c'est le modèle serverless qui fait le travail. ## La randomisation : l'équité par le shuffle Pourquoi mélanger les tokens avant l'envoi ? Parce que sans mélange, les tokens sont envoyés dans l'ordre dans lequel ils sont stockés en base — typiquement l'ordre d'inscription. Les premiers inscrits reçoivent toujours la notification en premier, les derniers toujours en dernier. Pour un bon plan qui expire vite, l'ordre de réception peut faire la différence entre "j'ai eu le code promo" et "le stock était épuisé". On ne veut pas que les utilisateurs anciens soient systématiquement avantagés. Un shuffle Fisher-Yates avant le découpage en chunks garantit une distribution équitable de l'ordre d'envoi. C'est un détail, mais c'est le genre de détail qui distingue un système de notification correct d'un système de notification juste. ## L'authentification : timing-safe et hashée La clé API n'est pas comparée avec un simple `===`. On utilise `timingSafeEqual` de Node.js, qui garantit que la comparaison prend le même temps quelle que soit la position du premier caractère différent. Ça prévient les attaques par timing — un attaquant ne peut pas deviner la clé caractère par caractère en mesurant le temps de réponse. ```typescript // pré-hash de la clé attendue au démarrage const hashedExpectedKey = createHash("sha256") .update(config.https.incoming_requests_auth_key) .digest() const apiKeyHeader = req.header("X-Api-Key") if (!apiKeyHeader) { return { valid: false, error: } } const hashedProvidedKey = createHash("sha256").update(apiKeyHeader).digest() if (!timingSafeEqual(hashedProvidedKey, hashedExpectedKey)) { return { valid: false, error: } } return } ``` On hash les deux côtés en SHA-256 avant de comparer. C'est une précaution supplémentaire : si les logs leakent, on ne voit que des hashes, pas la clé en clair. ## La construction du payload FCM : iOS et Android en un seul appel FCM accepte un payload unifié qui peut contenir des configurations spécifiques par plateforme. C'est un des avantages par rapport à Pushwoosh : on contrôle exactement ce qui est envoyé à APNs (iOS) et à GCM (Android), dans un seul appel. La fonction `getFirebaseMessageFromRequestParams` traduit notre format d'entrée (simple, orienté produit) en format FCM (détaillé, orienté plateforme) : ```typescript function getFirebaseMessageFromRequestParams( params: SendBulkPushNotificationsPayload, ): MessageInfo { // Notification commune const notification: MessageInfo["notification"] = if (params.title) notification.title = params.title if (params.imageUrl) notification.imageUrl = params.imageUrl const genericPriority = params.priority ?? "default" // --- iOS (APNs) --- const aps: admin.messaging.Aps = if (params.sound) aps.sound = if (typeof params.badge === "number") aps.badge = params.badge if (params.imageUrl) aps["mutable-content"] = true // Mapping priorité générique → interruption-level iOS if (params.ios?.interruptionLevel) else { switch (genericPriority) } // --- Android --- const androidNotification: admin.messaging.AndroidNotification = switch (genericPriority) // ...assemblage final return { notification, apns: { payload: }, android: } } ``` Le mapping entre notre `priority` générique et les concepts de chaque plateforme (`interruption-level` sur iOS, `channelId` + `priority` sur Android) est la valeur ajoutée de cette couche d'abstraction. Le backend appelant ne sait pas que iOS a des interruption levels et Android des notification channels — il envoie `priority: "time-sensitive"` et la Cloud Function fait le reste. ## Le TTL : une notation humaine La durée de vie d'une notification (combien de temps FCM essaie de la livrer si le téléphone est hors ligne) est exprimée en notation humaine : `30m` (30 minutes), `2h` (2 heures), `1d` (1 jour), `1w` (1 semaine). Le parsing est minimal : ```typescript const TTL_REGEXP = /^(\d+)(m|h|d|w)$/ function parseTTL(ttlString?: string) { if (!ttlString) return null const matches = TTL_REGEXP.exec(ttlString) if (!matches) return null return } ``` Pour iOS (APNs), le TTL est converti en timestamp Unix d'expiration. Pour Android, c'est une durée en millisecondes. Deux formats pour la même information — encore une fois, la Cloud Function absorbe cette complexité. ## La réponse : transparente sur les échecs La réponse de la fonction est conçue pour que l'appelant puisse agir : ```typescript // Succès partiel — certains tokens ont échoué ``` Les `failedTokens` sont des tokens à supprimer de la base — ils correspondent à des appareils qui se sont déconnectés ou qui ont désinstallé l'app. Le backend peut les nettoyer automatiquement après chaque envoi. En cas d'erreur réseau globale (Firebase injoignable), la réponse est un 500 avec `tokensToRetry` — la liste complète des tokens qui n'ont pas été traités. L'appelant peut les réessayer immédiatement. ```typescript // Échec total — Firebase injoignable ``` ## Le déploiement : GitHub Actions Le projet est déployé automatiquement sur push vers `main`. Le workflow GitHub Actions est en deux phases : compilation TypeScript, puis déploiement Firebase. La séparation permet de vérifier que le code compile avant de déployer : ```yaml jobs: build: steps: - run: npm ci --ignore-scripts && npm run build - uses: actions/upload-artifact@v2 deploy: needs: build steps: - uses: actions/download-artifact@v2 - run: firebase deploy --only functions ``` Le `sed -i '/predeploy/d' firebase.json` dans le workflow supprime le hook `predeploy` (qui recompile) puisque la compilation a déjà été faite dans le job `build`. C'est un micro-hack de CI, mais ça évite de compiler deux fois. ## Les premiers résultats du POC Après deux semaines de test en parallèle avec Pushwoosh : **Latence.** La Cloud Function + envoi parallèle livre 50 000 notifications en ~8 secondes. Pushwoosh prend entre 2 et 15 minutes pour le même volume, selon la charge du moment. **Fiabilité.** Le taux de livraison FCM est de 99.7% (les 0.3% sont des tokens invalides = appareils déconnectés). Pushwoosh oscille entre 97% et 99% selon les jours. **Coût.** FCM est gratuit. La Cloud Function consomme ~200ms de compute par invocation, ce qui entre largement dans le free tier Firebase. Pushwoosh facture au volume de notifications. **Contrôle.** On maîtrise le payload FCM exactement. Plus de mapping opaque entre le format Pushwoosh et ce que reçoit le téléphone. Les `interruption-level` iOS fonctionnent du premier coup, les notification channels Android sont ceux qu'on a définis. Le POC est concluant. La bascule complète est en cours de planification. --- _Cet article fait partie de notre série sur le projet chocobonplan : [la réécriture de l'app React Native](/blog/chocobonplan-rewrite-react-native-architecture-rematch) détaille l'architecture du client mobile._