Accueil / Articles / Sept hypothèses cachées qui font échouer JavaScript sous un trafic réel

Sept hypothèses cachées qui font échouer JavaScript sous un trafic réel

Découvrez quels sont les postulats concernant les types, les tentatives de répétition, la concurrence, l’ordre des réponses, le volume de données, les données manquantes et l’état en mémoire qui font échouer le code JavaScript une fois qu’il est mis en production.

3164 mots

La plupart des problèmes de JavaScript en environnement de production ne proviennent pas d’erreurs syntaxiques classiques. Ils étaient corrects dans des conditions qui n’existaient que sur l’ordinateur portable d’un développeur : ensembles de données minuscules, réponses rapides, un utilisateur prudent, un seul processus. Ci-dessous sont présentées sept de ces conditions cachées, la manière dont chacune échoue sous un trafic réel, ainsi que les modifications de conception concrètes qui permettent de transformer cela en une décision explicite et testée, plutôt qu’en une surprise lors d’un incident.

Pourquoi le développement local est un mauvais indicateur

La production modifie les données d’entrée plutôt que la langue. Les enregistrements ont été créés avec cinq versions différentes de l’application et ne présentent pas tous la même structure. Les utilisateurs font un double-clic parce que l’écran semble bloqué. Les réponses arrivent dans un ordre différent de celui des demandes envoyées. Les API tierces sont lentes, renvoient des données incomplètes ou se déconnectent. Plusieurs instances du même service mettent à jour les mêmes lignes en même temps. Les champs qui étaient toujours remplis localement apparaissent sous la forme de "", null, d’une valeur enum obsolète depuis deux versions, ou bien comme une chaîne dans laquelle devrait se trouver un nombre.

Rien de tout cela ne rend le code pire après déploiement. Il se contente d’éliminer les conditions qui faisaient paraître des limites fragiles comme étant sûres. L’habitude utile est de cesser à ne pas se demander uniquement « fonctionne-t-il avec les données attendues ? » et de commencer à se demander ce que le code suppose silencieusement concernant les délais, le volume, la propriété, la forme des données et les pannes. Beaucoup de ces suppositions sont tout à fait raisonnables. Le risque réside dans le fait de ne pas les mentionner jusqu’à ce que des utilisateurs réels viennent les contredire.

1. Les valeurs n’ont pas toujours le type qu’elles semblent avoir

Dans JavaScript, une valeur peut franchir plusieurs limites et perdre son type d’origine à chacune d’elles. Un identifiant de base de données numérique se transforme en chaîne de caractères une fois qu’il figure dans une URL. Une case à cocher est transmise au serveur sous la forme de "true" ou "false". Une entrée vide devient "" alors que le système backend s’attendait à null. Une date-timestamp ISO est envoyée en tant que texte brut et est traitée comme s’il s’agissait d’un objet Date simplement parce qu’elle en a l’apparence.

En environnement local, ce phénomène est rarement observé, car c’est le même développeur qui écrit les deux parties de la requête et que les données d’entrée sont déjà adaptées à la fonction testée. En production, les entrées proviennent de clients mobiles plus anciens, du comportement natif des formulaires dans les navigateurs, d’intégrations avec des partenaires, de données en cache obsolètes, ainsi que d’informations modifiées manuellement dans des outils administratifs.

La coercition produit des résultats qui semblent presque corrects

Les bugs graves sont ceux où JavaScript effectue une conversion plausible au lieu de lancer une erreur. "10" se compare correctement avec des nombres à l’aide de < et >, mais "10" + 1 produit "101". La chaîne de caractères "false" est considérée comme vraie, ce qui peut faire en sorte qu’une flag destinée à désactiver une fonctionnalité la mette en fait en activité. Une chaîne vide est considérée comme « manquante » dans un outil d’aide, mais comme une valeur valide dans un autre.

Rien ne plante. La pagination saute la mauvaise page, une option désactivée devient sélectionnable, ou userId === record.ownerId échoue silencieusement parce que l’une des valeurs est un nombre et l’autre une chaîne. Les équipes corrigent alors chaque symptôme où il apparaît, et la base de code accumule progressivement plusieurs règles de normalisation légèrement différentes.

Normalisez une seule fois, à la frontière

Les systèmes robustes convertissent les valeurs lorsqu’elles franchissent une limite significative, et ne le font plus jamais par la suite. Les paramètres de requête et les corps de demande sont analysés pour être transformés en types internes validés avant que la logique métier ne les traite. Les réponses provenant d’API externes sont mappées vers des modèles de domaine stables. Les entrées provenant des formulaires sont converties intentionnellement, plutôt que d’être traitées en fonction de leur valeur littérale ou de la coercition des opérateurs. L’objectif n’est pas de transformer JavaScript en un langage à typage strict ; il s’agit plutôt de s’assurer que le reste de l’application n’ait jamais à deviner la signification d’une valeur.

TypeScript documente la structure prévue, mais les types sont effacés en temps de exécution et ne permettent pas de vérifier ce qui arrive réellement. Un gestionnaire dont le paramètre est parfaitement typé peut néanmoins recevoir n’importe quoi. Le modèle plus sûr consiste à utiliser un schéma en temps de exécution ainsi qu’un type statique qui décrivent le même contrat, de préférence avec un type dérivé du schéma. Des bibliothèques de schémas comme Zod rendent cela pratique, car une seule définition peut à la fois valider en temps de exécution et générer le type TypeScript.

2. Un clic ne signifie pas une exécution

L’écran affiche un seul bouton, ce qui amène naturellement à imaginer une seule requête : l’utilisateur clique, le serveur effectue le travail, et la réponse confirme cela. Les tests manuels renforcent cette idée, car les développeurs cliquent une seule fois et attendent patiemment.

Les utilisateurs réels et les réseaux réels se comportent différemment. Quelqu’un clique à nouveau parce qu’aucun indicateur de chargement n’apparaît. Une application mobile tente à nouveau après la perte de connexion. Un proxy ou un équilibreur de charge retransmet une demande après une panne temporaire. Une file d’attente de messages relance une tâche parce que le processus chargé a terminé la mission mais s’est écrasé avant de l’confirmer. Ce qui semblait être un seul point d’accès comporte désormais plusieurs moyens de s’exécuter à deux reprises.

Où les doublons causent réellement des problèmes

Répéter une lecture est généralement inoffensif. En revanche, répéter la création d’un compte, une réservation de stock, un paiement, une invitation ou une exportation générée est problématique : cela entraîne des lignes dupliquées, plusieurs e-mails, une déduction du stock effectuée deux fois ou un client facturé à deux reprises.

Désactiver le bouton après la première clic améliore l’expérience utilisateur, mais ce n’est pas une garantie. Il est possible de contourner les contrôles, de réessayer les demandes en dehors de l’interface utilisateur, et deux instances du service peuvent chacune recevoir la même action logique. La protection côté interface est une mesure de courtoisie ; c’est le backend et la base de données qui doivent assurer le respect de cette invariance.

Concevoir des écritures tolérantes aux répétitions

Les écritures importantes doivent être conçues en partant du principe qu’elles seront tentées plus d’une fois :

La réflexion clé est qu’un délai d’attente ou une réponse d’erreur ne prouve pas que l’opération a échoué. Le serveur a pu terminer son travail après que le client ait abandonné l’attente. Tenter à nouveau sans une identité stable pour l’opération transforme cette incertitude en duplication. Un système fiable n’est pas celui qui empêche toutes les répétitions ; c’est celui où une répétition ne change pas ce que l’opération signifie finalement. Les mécanismes du côté serveur sont abordés plus en détail dans comprendre les clés d’idempotence dans les endpoints POST de Node.js.

3. Le code utilisant await peut encore présenter des conflits

async/await fait en sorte qu’une fonction ressemble à une séquence structurée : charger le record, vérifier son état, le mettre à jour, puis renvoyer les résultats. Chaque étape est attendue, ce qui donne l’impression que le flux est contrôlé. Cependant, ce contrôle n’existe qu’à l’intérieur d’une seule invocation.

Lorsqu’une appel est suspendu à l’aide d’un await, le moteur de exécution peut librement lancer un autre gestionnaire de requête, une fonction de rappel d’événement ou une tâche pour la même fonction. Deux invocations peuvent lire le même état, conclure que l’opération est autorisée dans les deux cas, et effectuer des écritures. Chaque ligne est correcte en soi, mais ensemble elles violent la règle métier.

Conflit de type vérification-action sur le serveur

Imaginez un point de terminaison d’approbation qui charge un élément en attente puis le met à l’état approuvé. Deux administrateurs ouvrent la même interface et cliquent sur « Approver » à quelques secondes d’intervalle. Les deux requêtes lisent l’état pending avant que chacune ne procède à une mise à jour. La ligne finale peut sembler correcte, mais si l’action envoie également une notification ou enregistre une entrée d’audit, ces effets secondaires se produisent deux fois.

Même conflit dans le navigateur

Dans l’interface utilisateur, le motif ressemble à une boîte de recherche. Une requête pour la consultation plus ancienne est lancée, suivie d’une requête pour la consultation plus récente ; la réponse la plus récente arrive en premier. L’écran affiche les bons résultats pendant un instant, puis la réponse plus ancienne arrive et les remplace. Les deux requêtes ont réussi et les mises à jour d’état ont utilisé des données valides. Ce qui manquait, c’était une décision quant à savoir quelle requête avait encore le droit de mettre à jour l’écran.

Un await ne prend pas de verrou, ne fige pas les valeurs lues précédemment, et ne met pas en file d’attente les appels à la même fonction. Il suspend simplement une exécution afin que d’autres tâches puissent se poursuivre.

Sélection d’un mécanisme de protection

La solution dépend du lieu où se produit la concurrence :

  • Une mise à jour conditionnelle telle que « mettre l’état en approuvé lorsque l’état est en attente », en vérifiant le nombre de lignes concernées.
  • Concurrence optimiste grâce à une colonne de version qui doit correspondre.
  • Une transaction de base de données avec un niveau d’isolation approprié.
  • Une contrainte d’unicité qui fait échouer bruyamment la deuxième écriture.
  • Annulation de la requête, ou un identifiant d’opération qui ne permet à que le résultat le plus récent d’accéder à l’état partagé.
  • La croyance dangereuse est que du code écrit de manière séquentielle implique un système qui se comporte également de façon séquentielle. JavaScript peut rendre une seule fonction très facile à suivre tout en exécutant simultanément de nombreuses copies superposées d’elle.

    4. Les requêtes ne s’achèvent pas dans l’ordre où elles ont commencé

    Puisque le code est écrit de haut en bas, il est tentant de raisonner sur les tâches asynchrones selon l’ordre de création : la requête A a été envoyée avant la requête B, donc A devrait revenir en premier. Les réseaux, les caches, les bases de données et les fournisseurs externes ne font aucune telle promesse.

    La première requête pourrait rencontrer une consultation lente tandis que la deuxième est servie depuis le cache. Une région de fournisseur répond immédiatement tandis qu’une autre réessaie en interne. Un gros volume de données met plus de temps à être analysé, même si le serveur a répondu rapidement.

    Les réponses obsolètes sont des données correctes au mauvais moment

    Cela devient un bug dès que l’ordre d’exécution détermine l’état actuel. Les résultats de recherche, la validation des formulaires, les chargeurs de routes, les tableaux de bord et l’autocomplétion sont les victimes habituelles : l’utilisateur progresse, mais une réponse tardive fait reculer l’interface. Les données contenues dans cette réponse ne sont pas fausses ; elles ne correspondent simplement plus à ce que l’utilisateur consulte.

    Le débouncing aide en réduisant le nombre de requêtes initiées, mais il n’élimine pas les chevauchements. Un utilisateur peut faire une pause suffisamment longue pour déclencher une requête, puis continuer à taper pendant qu’elle est encore en cours d’exécution, de sorte que la requête plus ancienne peut finir par être traitée en dernier.

    Décidez à qui appartient le résultat

    Une solution fiable commence par une règle de propriété. Dans de nombreuses interfaces, la demande la plus récente doit l’emporter ; vous pouvez donc soit annuler les demandes anciennes, soit y ajouter un numéro de séquence et éliminer les résultats qui ne sont plus à jour. D’autres flux de travail nécessitent un traitement par ordre d’arrivée, ou bien un état indépendant pour chaque opération. La même règle de propriété doit également s’appliquer aux états de chargement et d’erreur : une demande abandonnée ne doit pas désactiver l’indicateur de chargement pour la demande active, ni afficher une erreur pour une requête que l’utilisateur a déjà remplacée. Pour en savoir plus sur la gestion spécifique à React, consultez la recherche React avec une gestion claire de la propriété de l’état.

    En environnement de production, l’ordre dans lequel les promesses ont été créées n’est pas respecté. Votre code doit donc déterminer, au moment du règlement, si ce résultat reste pertinent.

    5. Les petites collections ne restent pas petites

    find sur un autre tableau pour chaque élément, filtrer une liste avec includes par rapport à une seconde, ou générer un rapport en filtrant l’ensemble des données une fois par groupe.

    Avec des fixtures de test, ces opérations s’effectuent instantanément, et le code reste suffisamment court pour que son coût algorithmique soit imperceptible. En environnement de production, les données augmentent sans que la structure du code ne change. Une opération find à l’intérieur d’une fonction map sur deux collections de dix mille enregistrements implique jusqu’à environ cent millions de comparaisons. Une utilisation de includes à l’intérieur d’une boucle ajoute un autre parcours linéaire par élément. Un rapport conçu pour une seule équipe est soudainement exécuté dans toute l’organisation.

    Adapter la structure au schéma d’accès

    Les méthodes d’array ne sont pas le problème ; c’est l’utilisation d’une structure séquentielle pour des recherches répétées par clé ou des tests d’appartenance qui le constitue. Créez un Map indexé par id une seule fois, et chaque recherche prendra en moyenne un temps constant. Utilisez un Set lorsque la question est « Est-ce que ceci fait partie de la collection ? ». Souvent, la meilleure solution consiste à effectuer une jointure avec une base de données, afin de ne pas charger les deux collections en mémoire et de ne pas les assembler dans le code de l’application.

    Cela ne signifie pas qu’il faut remplacer tous les arrays. La création d’un index a ses propres coûts, et pour une liste vraiment très petite, find peut être l’option la plus évidente. Le calcul change lorsque l’opération est exécutée fréquemment ou que les données peuvent augmenter considérablement.

    La mémoire et la concurrence évoluent également

    Le volume a également un impact sur la mémoire et l’utilisation des ressources. Charger chaque ligne avant le filtrage, enchaîner plusieurs appels map et filter qui allouent chacun un nouveau tableau, ou lancer une promesse par élément avec Promise.all peut être acceptable en environnement local mais destructeur en production. Le processus peut manquer de mémoire, épuiser le pool de connexions à la base de données ou monopoliser la boucle d’événements, ralentissant ainsi toutes les autres requêtes en attente. La pagination, le streaming et la limitation de la concurrence constituent les solutions habituelles.

    La plupart des problèmes de performance proviennent de code ordinaire confronté à un volume exceptionnel. Connaître l’échelle attendue, éviter les tâches inutiles et analyser le parcours réel avant de recourir à des optimisations ingénieuses est essentiel. La ligne la plus coûteuse est souvent celle qui semble trop familière pour être remise en question.

    6. L’absence de données n’est pas une preuve qu’il n’y a eu aucun problème

    JavaScript rend les solutions de secours simples et efficaces. La chaînage optionnel évite les erreurs d’accès aux propriétés, le coalescing nullish fournit des valeurs par défaut, et un bloc catch peut retourner un tableau vide. Ceux-ci sont utiles lorsque l’absence est attendue et comprise. Ils deviennent néfastes lorsqu’ils effacent la différence entre « il n’y a rien » et « nous n’avons pas pu le déterminer ».

    Lorsque les solutions de secours cachent des incidents

    Une requête sur le tableau de bord échoue car la base de données est indisponible, le service renvoie [], et l’interface affiche gaiement « Aucun enregistrement trouvé ». Une vérification des permissions échoue, la chaînage optionnel produit undefined, et le code traite cela comme un simple false. Une réponse mal formatée génère undefined qui se transforme en un objet par défaut trois niveaux plus bas. L’application semble stable car elle ne plante jamais, mais elle indique aux utilisateurs des informations qu’elle ne connaît pas réellement.

    Ces résultats ne sont pas interchangeables :

    • Une requête qui a été exécutée et qui a renvoyé zéro ligne par rapport à une requête qui n’a jamais été exécutée.
    • Un avatar optionnel manquant par rapport à un objet utilisateur manquant.
    • Un rejet délibéré de la part de l’entreprise par rapport à un temps d’attente réseau.

    Chacun peut avoir besoin de son propre message d’utilisateur, de sa propre politique de tentative répétée, ainsi que de ses propres procédures d’alerte et de support. En environnement de production, ces pannes sont courantes et non théoriques : les dépendances tombent en panne, les permissions changent, les déploiements progressifs génèrent temporairement des versions mixtes, et les anciennes données violent de nouvelles règles. Si chaque résultat anormal est considéré comme « vide », les incidents restent invisibles jusqu’à ce qu’un autre signal devienne suffisamment fort pour être remarqué.

    Définissez d’abord le contrat, puis ajoutez une syntaxe défensive

    Utilisez la chaînage optionnel lorsque une valeur est véritablement optionnelle. Utilisez une solution de repli lorsqu’il existe une alternative fiable que le système peut proposer. Lorsque vous capturez des erreurs, traduisez-les en catégories stables telles que « non trouvé », « interdit », « indisponible » ou « invalide », mais conservez la cause et le contexte originaux pour les journaux et la surveillance. Une dégradation élégante doit permettre à l’application de rester utile tout en étant fidèle à la réalité ; elle ne doit jamais faire croire au système qu’il fonctionne correctement en renvoyant une valeur plausible.

    7. L’état en mémoire n’est pas partagé à l’échelle de l’application

    L’étendue de module rend l’état en mémoire pratique. Une variable de niveau supérieur peut contenir un cache, suivre les tâches en cours, compter les requêtes pour la limitation de débit, ou se souvenir si l’initialisation a déjà eu lieu. Au niveau local, un seul processus traite chaque requête, de sorte que cette variable se comporte comme un état global de l’application.

    Dans un environnement de production, le même code peut s’exécuter dans plusieurs processus, conteneurs, instances serverless ou régions, et chacun dispose de sa propre mémoire :

    • Un cache mis à jour sur une instance reste obsolète sur les autres.
    • Un drapeau « déjà initialisé » ne protège que le processus qui l’a défini.
    • Un limiteur de fréquence en mémoire permet à un client de dépasser la limite simplement en se connectant à des instances différentes.
    • Un chronomètre planifié en mémoire disparaît lorsque le conteneur redémarre.

    Même un processus est moins permanent qu’il n’y paraît. Les déploiements le redémarrent, les plateformes serverless gèlent et recyclent les instances, les pannes effacent tout ce qui n’a pas été persisté, et en cas de pression mémoire, la plateforme peut tuer le processus sans lui donner l’occasion d’évacuer les tâches en attente.

    Donnez à l’état l’ampleur dont il a réellement besoin

    L’état en mémoire reste excellent pour les caches par processus, les optimisations locales, la coordination à court terme au sein d’une requête, ainsi que pour toute valeur dont la perte est acceptable. Les problèmes commencent lorsque l’on lui confie des responsabilités nécessitant une autorité ou une durabilité à l’échelle du système. Les limites de débit partagées devraient généralement être stockées dans un système central comme Redis. Les tâches qui doivent survivre aux redémarrages doivent être placées dans une file d’attente ou une base de données. Les verrous distribués nécessitent un mécanisme visible par tous les processus concurrents. Les configurations critiques doivent provenir d’une source fiable, et non d’une variable modifiable au sein d’une instance.

    Les questions à se poser portent sur la portée et la durée de vie. Cet état existe-t-il pour chaque appel de fonction, pour chaque session utilisateur, pour chaque processus, pour chaque déploiement, ou bien dans l’ensemble du système ? Et que se passe-t-il lorsqu’il s’agit d’un processus disparu ? Dans la plupart des architectures modernes, un environnement d’exécution JavaScript n’est pas l’application elle-même ; il s’agit simplement d’un participant temporaire au sein d’un système plus vaste.

    La production est plus honnête, pas plus aléatoire

    Il est tentant de qualifier la production d’imprévisible. Il est plus exact de dire qu’elle fournit enfin le calendrier, l’échelle, les données historiques, le nombre d’utilisateurs simultanés et les limites infrastructurelles que l’application était censée gérer depuis le début. JavaScript suit exactement les mêmes règles : les chaînes de caractères non vides sont considérées comme vraies, les fonctions asynchrones se chevauchent entre différentes invocations, les tableaux sont parcourus de manière linéaire, et les variables de module appartiennent à un seul environnement d’exécution. Les surprises proviennent des hypothèses pour lesquelles les conditions locales n’ont jamais contraint quiconque à effectuer des tests.

    Un code fiable ne cherche pas à se protéger contre chaque scénario imaginable. Il identifie les hypothèses qui entraîneraient de lourdes conséquences en cas d’erreur et les rend explicites. Le code supplémentaire est généralement de petite taille ; l’avantage réel réside dans l’élimination de l’ambiguïté. Le développeur suivant peut ainsi savoir quels valeurs sont valides, quelle opération produit un résultat, ce que signifie le succès et si une tentative de nouveau est sûre.

    Points clés

    • Analysez et validez les valeurs externes une seule fois à la frontière, en maintenant en synchronisation les schémas en temps de exécution et les types statiques.
    • Tenez compte du fait que chaque écriture importante peut être exécutée plus d’une fois, et assurez l’unicité des données là où elles sont stockées.
    • N’oubliez pas que await suspend une seule appel ; il ne sérialise pas le système ni ne verrouille l’état partagé.
    • Décidez quel résultat asynchrone correspond à l’état actuel, y compris les indicateurs de chargement et d’erreur.
    • Choisissez des structures de données et des requêtes adaptées au volume de données réel, et non à celui utilisé pour les tests.
    • Gardez les états « vides » et « échoués » comme des résultats distincts jusqu’au niveau de l’utilisateur et de votre système de surveillance.
    • Stockez les états selon l’ampleur et la durabilité réellement nécessaires, en supposant que n’importe quel processus peut disparaître.

    Lectures complémentaires