Apogée Consult
Retour au blog
Mathieu Ponton
Mathieu PontonCo-Founder & ingénieur logiciel

Je suis Mathieu Ponton, Co-Founder & ingénieur logiciel chez Apogée Consult à Lyon. Ingénieur diplômé de Polytech Lyon (Informatique), j'ai fait trois ans en apprentissage, partagés entre la Métropole de Lyon (inclusion numérique avec Res'in et sobriété énergétique avec Écolyo) et Superwyze, une startup medtech (POCs, dont certains aujourd'hui industrialisés, et travail sur des codebases existantes). J'ai livré plus de 10 projets en production (web, mobile et IA / RAG) pour des PME, startups et organisations publiques.

Hooks Payload async : les 3 pièges qui nous ont coûté une matinée chacun

  • payload

Trois bugs réels sur des hooks async dans Payload 3. Chaque erreur était invisible au développement et a explosé en production.

Hooks Payload async : les 3 pièges qui nous ont coûté une matinée chacun

Les hooks Payload sont puissants et faciles à brancher. C'est peut-être pour ça qu'on les utilise sans trop y réfléchir. Ces trois erreurs n'étaient pas visibles en développement et ont toutes causé des incidents en production.

Piège 1 : un afterChange qui échoue silencieusement

Le scénario : on envoie un email de notification quand un document passe en statut published. L'email est envoyé depuis un hook afterChange.

// Ce code a causé un incident
export const notifyOnPublish: AfterChangeHook = async ({ doc, previousDoc }) => {
  if (doc.status === 'published' && previousDoc?.status !== 'published') {
    await sendNotificationEmail(doc);
  }
};

Le problème : afterChange est appelé après que la transaction de base de données est validée. Si sendNotificationEmail lève une erreur, Payload ne la propage pas à l'appelant. L'erreur est swallowed par défaut, le document est bien sauvegardé, mais l'email n'est pas envoyé, et rien n'indique que ça a raté.

On l'a découvert trois jours après le déploiement, quand un client s'est plaint de ne pas recevoir ses notifications. Les logs ne montraient rien parce qu'on n'avait pas ajouté de try/catch explicite.

La correction :

export const notifyOnPublish: AfterChangeHook = async ({ doc, previousDoc }) => {
  if (doc.status !== 'published' || previousDoc?.status === 'published') return;

  try {
    await sendNotificationEmail(doc);
  } catch (err) {
    // L'email a raté, mais on ne veut pas que ça empêche la sauvegarde
    // On log explicitement pour avoir une trace
    console.error('[notifyOnPublish] Échec envoi email :', {
      docId: doc.id,
      error: err instanceof Error ? err.message : String(err),
    });
    // Optionnel : alerter via un service de monitoring
  }
};

Règle qu'on applique maintenant : tout hook afterChange qui effectue une opération externe (email, webhook, cache invalidation) est wrappé dans un try/catch avec logging explicite.

Piège 2 : modifier data dans un beforeChange sans retourner le bon objet

Le scénario : on voulait normaliser un slug avant la sauvegarde, convertir en minuscules, remplacer les espaces.

// Version cassée
export const normalizeSlug: BeforeChangeHook = async ({ data }) => {
  data.slug = data.slug?.toLowerCase().replace(/\s+/g, '-');
  // Pas de return
};

En développement, ça semblait fonctionner. En production, le slug était parfois sauvegardé non-normalisé, parfois normalisé. Le comportement était aléatoire selon les versions de Payload et les contexts d'appel.

La raison : dans un beforeChange, Payload utilise la valeur de retour du hook pour modifier data. Si le hook ne retourne rien (undefined), Payload conserve le data original non modifié. La mutation directe de data n'est pas garantie de fonctionner.

La correction :

export const normalizeSlug: BeforeChangeHook = async ({ data }) => {
  if (!data.slug) return data;

  return {
    ...data,
    slug: data.slug.toLowerCase().replace(/\s+/g, '-').trim(),
  };
};

On retourne toujours un objet complet depuis un beforeChange. Ne jamais muter data directement et supposer que c'est suffisant.

Piège 3 : des hooks qui s'empilent sur les relations imbriquées

Le scénario le plus insidieux. On avait un hook afterChange sur la collection Articles qui mettait à jour un compteur dans la collection Authors. Et un hook afterChange sur Authors qui invalidait du cache.

// Sur Articles
export const updateAuthorStats: AfterChangeHook = async ({ doc, req }) => {
  await req.payload.update({
    collection: 'authors',
    id: doc.author,
    data: { articlesCount: await countArticles(doc.author, req.payload) },
    req, // on passe req pour maintenir le contexte
  });
};

Le problème : appeler payload.update() depuis un hook déclenche les hooks de la collection cible. La mise à jour de Authors déclenchait son propre afterChange, qui lui-même tentait d'invalider du cache avec des données potentiellement incomplètes. On s'est retrouvé avec une cascade de hooks qui s'exécutaient dans un ordre non déterministe.

Dans le cas le plus grave, on a eu un appel circulaire : Articles afterChange → mise à jour AuthorsAuthors afterChange → revalidation d'une page qui chargeait Articles → nouvelle écriture sur Articles dans un contexte de test. Pas une boucle infinie, mais un comportement imprévisible.

La correction : utiliser disableReindexing pour les mises à jour internes depuis les hooks, ou passer par les opérations bas niveau qui ne déclenchent pas les hooks :

export const updateAuthorStats: AfterChangeHook = async ({ doc, req }) => {
  const count = await countArticles(doc.author, req.payload);

  // Mise à jour directe via Drizzle pour éviter les hooks
  // Payload expose db via req.payload.db
  await req.payload.db.drizzle
    .update(authorsTable)
    .set({ articlesCount: count })
    .where(eq(authorsTable.id, doc.author));
};

On perd les hooks Authors sur cette mise à jour, ce qui est exactement ce qu'on veut ici. Si on a besoin des hooks, on les appelle explicitement et dans l'ordre qu'on contrôle.

La règle qu'on a adoptée

Avant de brancher un hook, on pose trois questions :

1. Que se passe-t-il si ce hook échoue ? Si la réponse est "ça ne doit pas empêcher la sauvegarde", c'est un afterChange avec try/catch. Si la réponse est "ça doit bloquer la sauvegarde", c'est un beforeChange ou un beforeValidate qui lève une erreur.

2. Est-ce que ce hook modifie d'autres documents ? Si oui, doit-on déclencher les hooks de ces autres collections ? Souvent non. Utiliser Drizzle directement ou passer { hooks: false } quand Payload le supporte.

3. Est-ce que deux hooks peuvent modifier le même champ ? Si oui, l'ordre d'exécution dans le tableau hooks est garanti, mais la lisibilité du code souffre. On préfère consolider en un seul hook qui fait tout.

La question qu'on garde ouverte : Payload expose-t-il une API pour désactiver sélectivement un sous-ensemble de hooks lors d'une mise à jour programmatique, ou doit-on toujours passer par Drizzle pour les opérations internes sans hooks ?

Disponible pour de nouveaux projets

Un projet à concrétiser ?
Parlons-en, sans engagement.

Un échange de 30 minutes pour cadrer votre besoin, qualifier la faisabilité et vous proposer une trajectoire claire.

1// kick-off : réponse sous 24h
2const project = await apogee.scope({
3 type: 'web | mobile | IA',
4 timeline: '6 à 16 semaines',
5 approach: 'sur-mesure'
6})
7// → cadrage offert
Payload hooks async : 3 pièges en production | Apogée Consult