Chirac Njutapmvoui
Articles

Envoyer des notifications à grande échelle sans faire tomber l'application

15 septembre 2026
ArchitectureSpring BootScalabilitéNotifications

Sur HelpDigiSchool, chaque événement important — une note publiée, un devoir mis en ligne, un bulletin disponible, une échéance de paiement dépassée — doit prévenir les bonnes personnes : fondateurs et administrateurs d'établissement, enseignants, élèves du secondaire consultant leurs devoirs, étudiants, parents. Plus de 1 000 personnes peuvent être connectées en même temps, et chacune doit recevoir l'information sur plusieurs canaux à la fois : une cloche dans l'application, un push navigateur, un email, parfois WhatsApp.

Le piège classique, c'est d'envoyer tout ça en direct, dans le thread de la requête qui déclenche l'événement. Ça fonctionne très bien... jusqu'au jour où un administrateur clique sur "relancer tous les impayés" pour 1000 élèves d'un coup, ou où une requête mal ciblée part beaucoup plus loin que prévu. C'est exactement le genre d'incident qui m'a poussé à repenser l'architecture d'envoi plutôt que d'empiler des rustines.

Rester rapide avant même d'envoyer une notification

Avec un tel volume d'utilisateurs simultanés, le premier risque n'est pas l'envoi des notifications — c'est la lenteur perçue à la simple connexion à l'application.

Le symptôme le plus parlant : une page qui met plus de 30 secondes à charger les notes d'une classe de 170 élèves, avec des requêtes qui finissent en timeout. La cause, presque toujours la même : des requêtes N+1 qui interrogent la base élève par élève au lieu de récupérer les données en un seul aller-retour, combinées à un cache absent ou mal ciblé. Éliminer ces requêtes N+1 et introduire un cache sur les données les plus consultées a fait passer ce chargement à moins de 3 secondes.

Le second symptôme n'apparaît que sous charge réelle : un pool de connexions (base de données, cache) dimensionné pour un usage normal s'épuise dès qu'un nombre inhabituel d'utilisateurs se connecte en même temps, provoquant des erreurs 503 en cascade. Diagnostiquer cet épuisement et redimensionner le pool en conséquence a permis de faire passer le taux d'échec sous forte charge de 75% à moins de 10%.

Ces deux correctifs n'ont rien à voir avec les notifications elles-mêmes — mais sans eux, aucune architecture de file d'attente n'aurait de sens : on ne peut pas se permettre de ralentir l'envoi si la simple connexion à l'application est déjà le goulot d'étranglement.

Ne jamais envoyer en direct

La première règle que je me suis fixée : une action utilisateur ne doit jamais attendre qu'un envoi externe se termine. Créer une notification, c'est une écriture en base — rapide, dans la même transaction que l'action métier. L'envoi réel (push, email, WhatsApp) est délégué ailleurs, et la requête répond immédiatement.

Concrètement, ça veut dire faire la distinction entre deux choses qu'on confond souvent : accepter une demande d'envoi et l'exécuter. Accepter est instantané. Exécuter peut prendre du temps, échouer, devoir être retenté — et ne doit jamais bloquer qui que ce soit.

Une file d'attente plutôt qu'un thread pool

Pour les canaux les moins tolérants (WhatsApp en tête, où un envoi en masse trop rapide expose à un bannissement du numéro), j'ai mis en place une vraie file d'attente : chaque message à envoyer devient une ligne en base, avec un statut (en attente, en cours, envoyé, échoué) et une priorité. Un travailleur planifié va piocher un message à la fois, dans l'ordre de priorité, et l'envoie réellement.

Ce choix — une table plutôt qu'une queue applicative classique (Rabbit, Kafka...) — n'est pas un compromis par défaut : sur une infrastructure à plusieurs répliques, la base est déjà la source de vérité partagée, et un verrou de ligne au moment de la capture (SELECT ... FOR UPDATE SKIP LOCKED) suffit à garantir qu'un même message n'est jamais traité deux fois, sans coordination supplémentaire entre instances.

Un débit volontairement lent

Le contre-intuitif du problème : la solution n'est pas d'envoyer plus vite, mais plus lentement — et de manière irrégulière. Un canal comme WhatsApp pénalise les comportements trop mécaniques. Le travailleur planifié introduit donc un délai aléatoire entre chaque envoi, une pause plus longue toutes les quelques messages, un quota journalier par expéditeur, et une fenêtre horaire "humaine" (pas d'envoi à 3h du matin). En cas d'erreur répétée du fournisseur, un coupe-circuit temporaire arrête tout le canal plutôt que de s'acharner.

Pour les canaux plus tolérants — email, push navigateur — le calcul est différent : ils partent en tâche asynchrone sur un pool de threads dédié, avec une capacité de file bornée, pour absorber un pic sans le répercuter sur les threads qui servent les requêtes HTTP.

Ce que j'en retiens

Le réflexe naturel face à un pic de charge est d'ajouter des threads, des batchs plus gros, plus de parallélisme. Ici, la bonne réponse a été l'inverse : découpler l'acceptation de l'exécution, puis délibérément ralentir l'exécution jusqu'à un rythme que le système en aval — une base partagée entre plusieurs répliques, une API WhatsApp qui bannit les comportements suspects — peut soutenir indéfiniment. Une file d'attente lente et fiable bat toujours un envoi rapide qui finit par tomber.