On a livré la réécriture de l’app chocobonplan 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 :

const sendBulkPushNotificationsSchema =
  Joi.object<SendBulkPushNotificationsPayload>({
    message: Joi.string().required(),
    tokens: Joi.array().min(1).max(499).items(Joi.string()).required(),
    title: Joi.string(),
    imageUrl: Joi.string().uri(),
    openUrl: Joi.string().uri(),
    ttl: Joi.string().regex(/^\d+(m|h|d|w)$/),
    sound: Joi.boolean(),
    badge: Joi.number(),
    priority: Joi.string().valid("low", "default", "high", "time-sensitive"),
    // ... configs iOS et Android
  }).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.

envoi parallèle — 100 000 tokens en ~10 secondesBackend appelant1. fetch 100k tokens2. shuffle (Fisher-Yates)3. chunk par 4994. Promise.allSettled → 201 requêtes HTTP → en parallèle5. agrège résultatssuccessCount: 99847failureCount: 153instance #1 → 499 tokensinstance #2 → 499 tokensinstance #3 → 499 tokens… × 201 instancesinstance #201 → 298 tokensFirebase CloudMessagingsendMulticast()→ APNs (iOS)→ GCM (Android)~2s par batchséquentiel : 201 × 2s = 402s ≈ 7 minparallèle : max(201 instances × 2s) ≈ 10s

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.

import { createHash, timingSafeEqual } from "crypto"

// pré-hash de la clé attendue au démarrage
const hashedExpectedKey = createHash("sha256")
  .update(config.https.incoming_requests_auth_key)
  .digest()

export function validateApiKeyHeader(req: HttpRequest) {
  const apiKeyHeader = req.header("X-Api-Key")
  if (!apiKeyHeader) {
    return { valid: false, error: { code: "auth/missing-api-key", /* ... */ } }
  }

  const hashedProvidedKey = createHash("sha256").update(apiKeyHeader).digest()
  if (!timingSafeEqual(hashedProvidedKey, hashedExpectedKey)) {
    return { valid: false, error: { code: "auth/invalid-api-key", /* ... */ } }
  }

  return { valid: true, value: apiKeyHeader }
}

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) :

function getFirebaseMessageFromRequestParams(
  params: SendBulkPushNotificationsPayload,
): MessageInfo {
  // Notification commune
  const notification: MessageInfo["notification"] = {
    body: params.message,
  }
  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 = { name: "default" }
  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) {
    aps["interruption-level"] = params.ios.interruptionLevel
  } else {
    switch (genericPriority) {
      case "low":         aps["interruption-level"] = "passive"; break
      case "time-sensitive": aps["interruption-level"] = "time-sensitive"; break
      default:            aps["interruption-level"] = "active"
    }
  }

  // --- Android ---
  const androidNotification: admin.messaging.AndroidNotification = {}
  switch (genericPriority) {
    case "low":
      androidNotification.channelId = "choco_other_channel_id"
      androidNotification.priority = "low"
      break
    case "high":
    case "time-sensitive":
      androidNotification.channelId = "choco_time_sensitive_channel_id"
      androidNotification.priority = "high"
      break
    default:
      androidNotification.channelId = "choco_news_channel_id"
      androidNotification.priority = "default"
  }

  // ...assemblage final
  return { notification, apns: { payload: { aps } }, android: { notification: androidNotification } }
}

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 :

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 {
    value: parseInt(matches[1], 10),
    unit: matches[2] as "m" | "h" | "d" | "w",
  }
}

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 :

// Succès partiel — certains tokens ont échoué
{
  "successCount": 497,
  "failureCount": 2,
  "failedTokens": ["token_abc...", "token_def..."],
  "failedTokensErrors": ["messaging/invalid-registration-token", "messaging/registration-token-not-registered"]
}

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.

// Échec total — Firebase injoignable
{
  "successCount": 0,
  "failureCount": 499,
  "tokensToRetry": ["token_1...", "token_2...", "..."],
  "tokensToRetryError": "Could not connect to Firebase"
}

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 :

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 détaille l’architecture du client mobile.