Si tu as déjà géré des queues Laravel en prod, tu connais la scène. Un webhook Stripe se met à « rejouer » un événement. Un écran admin déclenche 50 updates en 10 secondes. Un import touche 10 000 lignes, et chaque ligne balance « un petit job » censé être inoffensif. Et là, ce n’est pas juste « c’est lent ». C’est une tempête : Redis chauffe, la DB prend des rafales, tes workers passent leur vie à refaire la même chose, et la vraie queue prioritaire (email, paiement, etc.) se fait marcher dessus.
Laravel 13.6 apporte un truc très simple et très rentable : des jobs debounceables. L’idée est bête dans le bon sens du terme : au lieu d’exécuter 100 fois le même job, tu laisses passer la rafale et tu n’en exécutes qu’un à la fin. Pas toujours. Pas partout. Mais quand ça colle au cas d’usage, c’est le genre de détail qui évite un incident.
Le scénario typique : un événement « innocent » qui spamme ta queue
Le piège, c’est que tout marche… jusqu’au jour où l’input n’est plus « humain ». Exemple vécu : un webhook (Stripe, CRM, WMS, ce que tu veux) se met à livrer des événements en doublon, ou avec des retries côté provider. Ton code, lui, est propre : tu reçois l’événement, tu fais une update, et tu dispatch un job « Sync » derrière. Sauf qu’en 30 secondes, tu as dispatch 200 jobs qui recalculent la même chose, souvent pour le même ID.
Ce qui fait mal, ce n’est même pas le coût d’un job. C’est le coût de la redondance plus le coût de l’orchestration. Chaque job sérialise du payload, tape Redis/SQS/DB, réveille un worker, refait 3 requêtes, réécrit des caches, repasse sur les mêmes index. Et tout ça pendant que les jobs « importants » attendent derrière.
Dans ce genre d’histoire, tu as deux options classiques. Soit tu sur-scales « pour encaisser ». Soit tu traites la cause racine : tu arrêtes de faire du travail inutile. Le debounce tombe pile dans la deuxième catégorie.
Sponsorisé par Le Scribouillard
Besoin de contenu optimisé SEO ?
Utilisez la meilleure plateforme française de création de contenu assistée par IA ! Et générez des articles pour moins de 1€ !
Debounce sur les jobs Laravel 13.6 : ce que ça garantit (et ce que ça ne garantit pas)
Le debounce, ce n’est pas « rendre le job unique ». C’est plutôt « ne garder que le dernier ». Autrement dit : si le même job est dispatch plusieurs fois dans une fenêtre de temps, Laravel va éviter d’en exécuter une rafale et va finir par exécuter un seul job quand la rafale se calme.
Ce que ça garantit, en pratique, c’est un comportement de lissage. Tu évites les cascades inutiles quand un signal est bruité. Ce que ça ne garantit pas, et c’est important de le dire : ça ne rend pas ton système magiquement « correct ». Si ton job n’est pas idempotent, si tu as des side effects irréversibles, si tu as des invariants métier stricts… le debounce peut masquer un symptôme et te créer une incohérence silencieuse.
Je le vois comme un outil de plus dans la boîte « protection anti-bruit ». Comme un rate limiting ou un circuit breaker. Très utile. Pas une excuse pour ignorer la conception des jobs.
Comment l’activer : attribute #[DebounceFor] ou debounceFor() au dispatch
Laravel 13.6 te laisse déclarer le debounce directement sur le job (pratique quand tu veux que ce soit un contrat), ou au moment du dispatch (pratique quand tu veux le faire varier selon le contexte).
Sur le job, ça donne typiquement un attribut. L’idée est que le job « sait » qu’il est debounceable, et sur quelle fenêtre.
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Attributes\DebounceFor;
#[DebounceFor(30)] // 30 secondes de "silence" avant d'exécuter
class SyncUserToSearch implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $userId) {}
public function handle(): void
{
// Recalcule et push dans ton index
}
}
Et si tu préfères le faire au dispatch, tu peux appliquer un debounce « à la volée ». C’est typiquement ce que je fais quand la fenêtre dépend d’un contexte (par exemple un écran admin où l’utilisateur peut spammer « enregistrer », versus un webhook où je veux être plus agressif).
SyncUserToSearch::dispatch($user->id)
->debounceFor(30);
Petit réflexe terrain : si tu introduces ça, logge au début. Pas en mode « debug permanent », juste le temps de vérifier que tu as bien le comportement attendu. C’est le genre de feature qui « marche » mais que tu peux mal cibler, et tu veux le voir noir sur blanc.
Les cas où le debounce est franchement une bonne idée : « ne garder que le dernier »
Le debounce brille quand ton job représente un état final, pas une suite d’événements qui doivent tous être pris en compte. Si tu recalcules un agrégat, si tu rebuild un cache, si tu synchronises un document vers un moteur de recherche, ce qui t’intéresse à la fin, c’est « la version la plus récente ».
Concrètement, c’est parfait pour un job de réindexation quand un modèle est modifié 20 fois en 10 secondes. Pareil pour un « refresh » de cache dérivé d’une entité. Ou encore pour les recalculs d’analytics internes quand tu sais que tu vas retoucher la même ressource plusieurs fois pendant un import.
Le gain est double. Tu réduis la charge sur la queue, et tu réduis la charge sur les systèmes aval (DB, Elasticsearch/Meilisearch/OpenSearch, APIs internes). Et souvent, c’est précisément l’aval qui craque pendant une tempête.
Les cas où c’est une mauvaise idée : side effects, facturation, emails
Le debounce devient dangereux dès que « chaque occurrence compte ». Si tu factures, si tu envoies des emails transactionnels, si tu écris dans une compta, si tu fais des appels API qui déclenchent des actions externes… « ne garder que le dernier » n’a plus rien d’un lissage, c’est une perte d’information.
Le piège, c’est de se dire « c’est bon, ça évite les doublons ». Non. Ça évite les rafales dans une fenêtre de temps, et ça peut supprimer des occurrences légitimes. Si ton workflow a besoin de traiter chaque événement (même si c’est le même userId), le debounce n’est pas la bonne arme.
Et même sur des jobs « safe », garde une obsession : idempotence. Parce qu’entre un retry, une relivraison, un worker kill -9 ou un timeout, tu auras de toute façon des répétitions. Le debounce réduit le bruit, il n’abolit pas la réalité.
Debounce vs jobs uniques vs rate limiting : trois outils, trois comportements
Laravel a déjà des mécanismes pour éviter les doublons, notamment les jobs « uniques ». La nuance qui compte : un job unique, en général, va plutôt garantir « un seul job à la fois » ou « le premier gagne » pendant une durée. Le debounce, lui, est plus orienté « le dernier gagne ».
En pratique, je m’en sers différemment. Les jobs uniques, je les aime quand j’ai une ressource à protéger et que je ne veux pas deux traitements concurrents (exemple : deux jobs qui finalisent une commande). Le debounce, je l’aime quand j’ai un signal spammy et que je veux réduire le travail. Et le rate limiting, je le garde pour les cas où je dois protéger un service aval avec une limite stricte (API externe, DB sous contrainte, etc.), même si ça veut dire « on met en attente » au lieu de « on drop ».
Tu peux aussi les combiner, mais là tu dois savoir ce que tu fais. Debounce + unique mal pensés peuvent te donner un comportement difficile à lire en incident, parce que tu ne sais plus ce qui a été ignoré, relâché, ou retardé.
Retries, backoff, Horizon : les interactions qui te surprennent en prod
Le debounce ne remplace pas une stratégie de retry. Si ton job échoue parce qu’un service aval est down, tu veux des retries et un backoff cohérent. Ce que tu veux éviter, par contre, c’est le scénario où une panne aval déclenche à la fois des retries et une rafale de nouveaux jobs identiques. C’est là que ça part en feu.
Si tu utilises Horizon, la question à te poser est simple : est-ce que tu vois une chute du « dispatched » utile, ou juste une explosion du « processed » qui ne sert à rien. Quand tu ajoutes le debounce sur un job qui était spammy, tu dois voir une baisse nette du volume traité, et souvent une baisse du lag sur les queues critiques. Si tu ne vois rien bouger, c’est que tu n’as pas ciblé le bon job, ou pas la bonne clé logique (par exemple tu debounce globalement alors que tu devrais debounce par ressource).
Autre point : pense « priorités ». Un bon debounce posé sur une queue dédiée, c’est propre. Un debounce posé sur une queue partagée avec du critique, ça peut créer des effets de bord si tu as mal séparé tes files et tes workers. Ce n’est pas un défaut du debounce, c’est un défaut de discipline sur les queues.
Observabilité : comment prouver que tu as arrêté la tempête
Quand tu corriges une tempête de jobs, tu veux des preuves. Pas juste « on a l’impression que c’est mieux ». Sur le terrain, ce que je regarde, c’est l’écart entre les jobs dispatchés et les jobs réellement exécutés, l’évolution de la longueur de queue (ou du backlog), et la latence entre dispatch et run. Si tu as un dashboard Horizon, c’est généralement visible en quelques minutes pendant un pic.
Tu peux aussi le rendre ultra concret avec des logs. Avant, tu avais 100 lignes « SyncUserToSearch started userId=42 » en 30 secondes. Après, tu veux en voir une ou deux, avec une exécution un peu plus tardive. C’est simple, mais ça met tout le monde d’accord, y compris en post-mortem.
Et oui, surveille aussi l’infra. Une tempête se voit souvent dans Redis (CPU, latence), dans la DB (connexions, locks, pics de requêtes répétées), et dans ton provider queue (SQS, etc.) via le volume de messages.
Les pièges que je vois passer : TTL, clé de dédoublonnage, multi-tenant
Le premier piège, c’est de choisir une fenêtre au pif. 30 secondes peuvent être parfaites pour un job de « refresh cache » qui n’a pas besoin d’être instantané, et catastrophiques pour un job qui pilote une expérience utilisateur. Le bon réglage, c’est celui qui absorbe la rafale typique sans rendre le système mou. Et ça, tu le trouves en regardant tes pics réels.
Le deuxième piège, c’est la notion de « même job ». Dans une app multi-tenant, tu veux rarement debounce « globalement ». Tu veux debounce par ressource et par tenant. Sinon tu vas finir par faire patienter un tenant à cause du bruit d’un autre, ce qui est un bug de produit, pas un bug technique.
Le troisième piège, c’est de croire que le debounce te dispense de rendre ton job propre. Si un job fait des effets de bord, protège-toi par conception : idempotence, verrous quand c’est nécessaire, invariants métiers clairs, et retries avec backoff. Le debounce doit rester une optimisation de charge, pas un pansement sur une logique fragile.
Conclusion : un « petit » debounce, c’est souvent un gros gain de stabilité
Je suis content de voir Laravel ajouter ce genre de feature, parce que c’est exactement ce qui manque quand tu passes du « ça marche » à « ça encaisse ». Le debounce sur les jobs, ce n’est pas glamour. Mais si tu as déjà vu une queue partir en vrille à cause d’un signal bruité, tu sais que ce sont ces détails qui font la différence.
Mon conseil : commence par un job clairement « état final » (index, cache, agrégats), pose un debounce conservateur, mesure l’avant/après sur un vrai pic, puis élargis. Et garde en tête la règle simple : si chaque occurrence compte, ne debounce pas. Tu ne veux pas un système calme et faux.