Async JavaScript - Debugger l'asynchrone
Ce que tu vas apprendre
- Pourquoi les stack traces asynchrones sont tronquees (et comment les récupérer)
- L'option "Async" dans Chrome DevTools et ce qu'elle fait concrètement
- Utiliser
console.trace()pour cartographier les chemins d'exécution - Logger les opérations asynchrones de facon structuree
- Placer des
debuggerdans du code async sans perdre le contexte - Trouver l'origine des rejections non gerees
- Les flags Node.js
--trace-warningset--unhandled-rejections
Prerequisites
- 02 - Promises : comprendre la chaîne de Promises
- 03 - async/await : syntaxe de base
- Savoir ouvrir les DevTools de ton navigateur
Le bug le plus frustrant de ma carriere impliquait une Promise rejetee. L'erreur dans la console disait Unhandled Promise rejection: Cannot read properties of undefined (reading 'id'). La stack trace pointait vers une ligne dans un fichier que je n'avais pas écrit, quelque part dans un .then() anonyme, trois niveaux de callbacks plus loin. Aucune indication sur QUI avait appele cette Promise, ni depuis OU. J'ai mis quatre heures a trouver. Aujourd'hui, je sais que ca aurait pu prendre cinq minutes avec les bons outils.
Le problème des stack traces asynchrones
En JavaScript synchrone, la stack trace est complète. Chaque fonction appelante apparaît dans la pile.
javascriptfunction c() { throw new Error("boom"); }
function b() { c(); }
function a() { b(); }
a();
// Error: boom
// at c
// at b
// at a
En asynchrone, la stack est coupee a chaque franchissement de boundary asynchrone :
javascriptasync function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json(); // Erreur ici
return data;
}
async function loadDashboard() {
const user = await fetchUser(42);
return user;
}
loadDashboard();
// Error: Unexpected token '<'
// at fetchUser (app.js:3)
// Et c'est tout. Qui a appele fetchUser ? Disparu.
Pourquoi ? Parce que quand fetchUser reprend apres le await, la stack d'origine (celle qui contenait loadDashboard) a ete depilee. La microtask qui reprend fetchUser est une nouvelle entree dans la boucle d'événements, avec sa propre stack.
Chrome DevTools : l'option Async stack traces
Chrome capture et reconstitue les stack traces asynchrones automatiquement depuis la v73. Dans les DevTools (F12), ouvre l'onglet Sources, et dans le panneau Call Stack, tu verras les frames asynchrones separees par un label gris "async".
fetchUser (app.js:3)
-- async --
loadDashboard (app.js:8)
-- async --
(anonymous) (app.js:12)
Pour activer ou vérifier cette option : DevTools > Settings (engrenage) > cocher "Capture async stack traces". C'est active par défaut, mais je l'ai deja vu désactivé sur des machines de collegues.
Le coût en mémoire est réel. Chrome limite la profondeur a 32 frames asynchrones par défaut. Si tu chaînes 40 awaits en cascade (ce qui serait un autre problème), les premières frames disparaissent.
console.trace() pour cartographier l'exécution
console.trace() affiche la stack trace a l'endroit ou tu l'appelles, sans lever d'erreur. C'est mon outil préféré pour comprendre "qui appelle quoi" dans du code asynchrone.
javascriptasync function saveOrder(order) {
console.trace("saveOrder appele avec", order.id);
// ...
}
La sortie dans la console :
saveOrder appele avec 42
at saveOrder (orders.js:2)
at processCheckout (checkout.js:15)
at handleSubmit (form.js:8)
at HTMLFormElement.onsubmit (index.html:12)
C'est non destructif (pas de throw), ca ne casse pas le flux, et ca te donne le chemin complet. Combine avec les async stack traces de Chrome, tu as une vision complète du parcours.
Un pattern que j'utilise souvent pour tracer les opérations async sans polluer la console :
javascriptconst DEBUG_ASYNC = true;
function traceAsync(label) {
if (!DEBUG_ASYNC) return;
const err = new Error();
const stack = err.stack.split("\n").slice(1, 5).join("\n");
console.groupCollapsed(`[ASYNC] ${label}`);
console.log(stack);
console.log("Timestamp:", performance.now().toFixed(2));
console.groupEnd();
}
async function fetchData(url) {
traceAsync(`fetchData(${url})`);
const response = await fetch(url);
traceAsync(`fetchData(${url}) - response recu`);
return response.json();
}
console.groupCollapsed est genial pour ca : la trace est visible mais repliee, elle ne noie pas les autres logs.
Logger les opérations asynchrones
Pour le debugging en production (ou semi-production), j'utilise un pattern de logging structure :
javascriptlet asyncOpId = 0;
function asyncLog(operation, data = {}) {
const id = ++asyncOpId;
const start = performance.now();
console.log(`[${id}] START ${operation}`, data);
return {
success(result) {
const duration = (performance.now() - start).toFixed(1);
console.log(`[${id}] OK ${operation} (${duration}ms)`, result);
},
fail(error) {
const duration = (performance.now() - start).toFixed(1);
console.error(`[${id}] FAIL ${operation} (${duration}ms)`, error);
},
};
}
// Utilisation
async function chargerProfil(userId) {
const log = asyncLog("chargerProfil", { userId });
try {
const user = await fetchUser(userId);
const prefs = await fetchPreferences(userId);
log.success({ user: user.name, prefsCount: Object.keys(prefs).length });
return { user, prefs };
} catch (err) {
log.fail(err);
throw err;
}
}
La sortie :
[1] START chargerProfil { userId: 42 }
[2] START chargerProfil { userId: 99 }
[1] OK chargerProfil (234.5ms) { user: "Alice", prefsCount: 7 }
[2] FAIL chargerProfil (1502.3ms) Error: 404 Not Found
Les IDs permettent de suivre les opérations qui se chevauchent. Les durees revelent les goulots d'etranglement.
debugger dans du code async
Le mot-clé debugger fonctionne dans du code async exactement comme dans du code synchrone. Le navigateur met l'exécution en pause quand il atteint cette ligne.
javascriptasync function processerCommande(commande) {
const stock = await verifierStock(commande.items);
debugger; // Pause ici : inspecte stock dans le scope
const prix = calculerTotal(stock, commande.reduction);
const paiement = await debiterCarte(commande.carte, prix);
debugger; // Pause ici : verifie le resultat du paiement
return confirmer(commande.id, paiement.transactionId);
}
Quand tu es en pause sur un debugger apres un await, la variable locale (stock, prix) est accessible dans le scope. Le panneau "Scope" des DevTools affiche les variables locales de la fonction async, meme si techniquement la stack a ete reconstruite.
Un piège classique : mettre un debugger dans un .then(). Ca marche, mais le contexte affiche est celui du callback, pas celui de la chaîne de Promises.
javascript// Prefere ca :
const data = await fetchData();
debugger;
traiter(data);
// Plutot que ca :
fetchData().then(data => {
debugger; // Fonctionne, mais le scope est moins clair
traiter(data);
});
Trouver la source des rejections non gerees
Les rejections non gerees sont le cauchemar du debugger async. Voici comment les traquer.
Dans le navigateur
javascript// Ecouter toutes les rejections non gerees
window.addEventListener("unhandledrejection", event => {
console.error("Promise rejetee non geree :", event.reason);
console.error("Promise :", event.promise);
// Capturer la stack si c'est une Error
if (event.reason instanceof Error) {
console.error("Stack :", event.reason.stack);
}
});
Dans Node.js
javascriptprocess.on("unhandledRejection", (reason, promise) => {
console.error("Rejection non geree :", reason);
console.error("Promise :", promise);
});
Node.js : flags utiles
bash# Afficher les warnings avec la stack trace complete
node --trace-warnings app.mjs
# Faire planter le process sur une rejection non geree (recommande en production)
node --unhandled-rejections=throw app.mjs
# Les deux ensemble
node --trace-warnings --unhandled-rejections=throw app.mjs
--trace-warnings est precieux. Sans ce flag, Node.js affiche un warning générique. Avec, il affiche la stack trace complète qui mene a la Promise rejetee.
Techniques avancees
Nommer tes Promises
Les Promises anonymes sont difficiles a tracer. Un pattern simple :
javascriptfunction namedPromise(name, executor) {
const p = new Promise(executor);
p._debugName = name;
return p;
}
// En debug, tu peux inspecter promise._debugName
const userPromise = namedPromise("fetchUser:42", (resolve, reject) => {
fetch("/api/users/42").then(r => r.json()).then(resolve).catch(reject);
});
Performance timeline
L'API Performance permet de mesurer les opérations async dans le Performance tab des DevTools :
javascriptasync function operationTracee(nom, fn) {
performance.mark(`${nom}-start`);
try {
const result = await fn();
performance.mark(`${nom}-end`);
performance.measure(nom, `${nom}-start`, `${nom}-end`);
return result;
} catch (err) {
performance.mark(`${nom}-error`);
performance.measure(`${nom}-failed`, `${nom}-start`, `${nom}-error`);
throw err;
}
}
// Visible dans DevTools > Performance > Timings
await operationTracee("charger-dashboard", () => fetchDashboard());
Résumé
Les stack traces asynchrones sont naturellement tronquees a chaque boundary async, mais Chrome DevTools les reconstitue automatiquement (option "Async stack traces"). console.trace() permet de cartographier les chemins d'exécution sans casser le flux. Un logger structure avec IDs et durees aide a suivre les opérations concurrentes. Le mot-clé debugger fonctionne dans du code async et donne acces au scope local reconstruit. Pour les rejections non gerees, l'événement unhandledrejection (navigateur) et --trace-warnings (Node.js) sont tes meilleurs allies. La clef du debug async, c'est l'instrumentation preventive : n'attends pas d'avoir un bug pour ajouter du logging.
Retrouve d'autres articles techniques sur paltemps.fr.
Navigation : Precedent : 15 - Top-level await | Suivant : 17 - Performance
Sources
- Chrome DevTools - Debug async JavaScript par Chrome Developers
- Node.js - CLI options (--trace-warnings) par Node.js
- MDN - Window: unhandledrejection event par Mozilla