Async JavaScript - Performance asynchrone
Ce que tu vas apprendre
- Le coût réel de création d'une Promise (micro mais mesurable a l'échelle)
- L'anti-pattern waterfall : des awaits sequentiels qui pourraient etre parallèles
- Le batching avec
Promise.allvs les appels sequentiels - Le connection pooling et pourquoi ca change tout
- Le prefetching : lancer les requêtes avant d'en avoir besoin
- Le lazy loading avec
import()dynamique - Mesurer la performance de ton code asynchrone
Prerequisites
- 04 - Promise.all, race, allSettled, any : les combinateurs de Promises
- 16 - Debugger l'asynchrone : instrumentation et mesure
L'annee dernière, j'ai audite une app React qui mettait 4.7 secondes a afficher son dashboard. Quatre requêtes API, chacune prenant environ une seconde. Le code etait propre, lisible, bien type. Le problème etait tellement évident que j'ai failli le rater : les quatre await etaient l'un apres l'autre. Sequentiels. 1 + 1 + 1 + 1 = 4 secondes. Un Promise.all plus tard, on etait a 1.1 seconde. Meme nombre de requêtes, meme serveur, quatre fois plus rapide.
Le coût de création d'une Promise
Creer une Promise n'est pas gratuit. Le moteur alloue un objet, initialise l'état interne, et enregistre la microtask pour la résolution. Sur V8 (Chrome, Node.js), ca represente environ 0.3 a 0.5 microsecondes par Promise créée.
javascript// Benchmark naif
const iterations = 1_000_000;
const start = performance.now();
for (let i = 0; i < iterations; i++) {
new Promise(resolve => resolve(i));
}
const duree = performance.now() - start;
console.log(`${iterations} Promises en ${duree.toFixed(1)}ms`);
// ~ 300-500ms pour 1 million de Promises
0.5 microsecondes, ca parait negligeable. Et ca l'est, pour 10 ou 100 Promises. Mais dans une boucle qui traite 100 000 éléments, ca fait 50ms rien qu'en création de Promises. Ajoute les microtasks de résolution, et tu atteins facilement le seuil des 100ms ou l'utilisateur commence a percevoir un delai.
La leçon : n'emballe pas tout dans des Promises. Si une opération est synchrone, laisse-la synchrone.
javascript// Inutile : creer une Promise pour un calcul synchrone
async function additionner(a, b) {
return a + b; // Emballe dans une Promise pour rien
}
// Mieux : garder synchrone ce qui est synchrone
function additionner(a, b) {
return a + b;
}
L'anti-pattern waterfall
C'est le problème de performance async le plus frequent. Des await sequentiels sur des opérations independantes.
javascript// MAUVAIS : waterfall - chaque requete attend la precedente
async function chargerDashboard(userId) {
const user = await fetchUser(userId); // 800ms
const orders = await fetchOrders(userId); // 600ms
const notifications = await fetchNotifs(userId); // 400ms
const recommendations = await fetchRecos(userId); // 500ms
// Total : 2300ms
return { user, orders, notifications, recommendations };
}
Ces quatre requêtes ne dependent pas les unes des autres. fetchOrders n'a pas besoin du résultat de fetchUser. Pourtant, le code les exécuté en serie.
javascript// BIEN : parallele - toutes les requetes partent en meme temps
async function chargerDashboard(userId) {
const [user, orders, notifications, recommendations] = await Promise.all([
fetchUser(userId), // 800ms
fetchOrders(userId), // |--- 600ms
fetchNotifs(userId), // |--- 400ms
fetchRecos(userId), // |--- 500ms
]);
// Total : ~800ms (le max des quatre)
return { user, orders, notifications, recommendations };
}
Le gain est proportionnel au nombre de requêtes independantes. Deux requêtes de 500ms en parallèle = 500ms au lieu de 1000ms. Dix requêtes = meme chose, 500ms au lieu de 5000ms.
Quand le waterfall est justifie
Parfois, l'ordre est impose par les donnees :
javascript// Ici le waterfall est correct : chaque etape depend de la precedente
async function chargerProfil(userId) {
const user = await fetchUser(userId);
const team = await fetchTeam(user.teamId); // Besoin de user.teamId
const perms = await fetchPermissions(team.roleId); // Besoin de team.roleId
return { user, team, perms };
}
Mais meme dans ce cas, tu peux parfois paralleliser partiellement :
javascriptasync function chargerProfil(userId) {
const user = await fetchUser(userId);
// team et avatar sont independants l'un de l'autre
const [team, avatar] = await Promise.all([
fetchTeam(user.teamId),
fetchAvatar(user.avatarUrl),
]);
const perms = await fetchPermissions(team.roleId);
return { user, team, avatar, perms };
}
Batching : regrouper pour réduire
Le réseau a un coût fixe par requête : handshake TCP, headers HTTP, sérialisation/désérialisation. Faire 100 requêtes individuelles est massivement plus lent que faire 1 requête qui ramene 100 résultats.
javascript// MAUVAIS : une requete par utilisateur
async function chargerUtilisateurs(ids) {
const users = [];
for (const id of ids) {
const user = await fetchUser(id); // 100 requetes sequentielles
users.push(user);
}
return users;
}
// MOYEN : parallele mais toujours 100 requetes
async function chargerUtilisateurs(ids) {
return Promise.all(ids.map(id => fetchUser(id)));
}
// BIEN : une seule requete batch
async function chargerUtilisateurs(ids) {
const response = await fetch("/api/users/batch", {
method: "POST",
body: JSON.stringify({ ids }),
});
return response.json();
}
Si ton API ne supporte pas le batch, tu peux au moins limiter la concurrence pour ne pas saturer le serveur :
javascriptasync function parallelLimit(tasks, limit) {
const results = [];
const executing = new Set();
for (const task of tasks) {
const p = task().then(result => {
executing.delete(p);
return result;
});
executing.add(p);
results.push(p);
if (executing.size >= limit) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
// Maximum 5 requetes en parallele
const users = await parallelLimit(
ids.map(id => () => fetchUser(id)),
5
);
Connection pooling
Chaque fetch() ouvre potentiellement une nouvelle connexion TCP. Le navigateur limite le nombre de connexions simultanees par domaine (6 en HTTP/1.1, theoriquement illimite en HTTP/2 grace au multiplexing).
En Node.js, le comportement par défaut est plus restrictif. Le module http maintient un pool de 5 connexions par défaut. Si tu fais 20 requêtes simultanees, 15 attendent qu'une connexion se libéré.
javascriptimport http from "node:http";
// Augmenter le pool de connexions
const agent = new http.Agent({
keepAlive: true,
maxSockets: 25, // Max connexions par host
maxTotalSockets: 100, // Max connexions totales
keepAliveMsecs: 30000, // Garder les connexions 30 secondes
});
// Utiliser avec fetch (Node 18+)
const response = await fetch("https://api.example.com/data", {
dispatcher: agent,
});
Avec des librairies comme undici (le moteur HTTP de Node.js moderne), le pooling est encore plus efficace.
Prefetching : anticiper les besoins
Le meilleur moyen d'accélérer une requête, c'est de la lancer avant d'en avoir besoin.
javascript// Pattern : prefetch au survol de souris
const cache = new Map();
function prefetch(url) {
if (!cache.has(url)) {
cache.set(url, fetch(url).then(r => r.json()));
}
return cache.get(url);
}
// Au survol du lien
document.querySelector("a.product-link").addEventListener("mouseenter", () => {
prefetch("/api/products/42"); // Lance la requete pendant que l'utilisateur vise le clic
});
// Au clic, les donnees sont probablement deja la
document.querySelector("a.product-link").addEventListener("click", async (e) => {
e.preventDefault();
const data = await prefetch("/api/products/42"); // Resolu instantanement si le prefetch a fini
afficherProduit(data);
});
Il y a en moyenne 200 a 300ms entre le mouseenter et le click. C'est souvent assez pour qu'une requête API revienne. L'utilisateur a l'impression que la page est instantanee.
Lazy loading avec import() dynamique
import() dynamique charge un module a la demande. C'est l'arme ultime pour réduire le bundle initial.
javascript// Charger un editeur lourd seulement quand l'utilisateur en a besoin
async function ouvrirEditeur() {
const { default: CodeMirror } = await import("codemirror");
const editor = new CodeMirror(document.getElementById("editor"), {
mode: "javascript",
});
return editor;
}
// Combiner avec prefetch
let editorPromise = null;
document.querySelector("#tab-code").addEventListener("mouseenter", () => {
editorPromise = editorPromise || import("codemirror");
});
document.querySelector("#tab-code").addEventListener("click", async () => {
const { default: CodeMirror } = await (editorPromise || import("codemirror"));
// ...
});
Le bundler (Webpack, Vite, esbuild) détecté les import() dynamiques et créé automatiquement des chunks separes. Pas de configuration supplementaire.
Mesurer la performance async
Tu ne peux pas optimiser ce que tu ne mesures pas. Voici comment mesurer proprement.
Performance API
javascriptasync function mesurer(nom, fn) {
const marqueDebut = `${nom}-start`;
const marqueFin = `${nom}-end`;
performance.mark(marqueDebut);
const result = await fn();
performance.mark(marqueFin);
const mesure = performance.measure(nom, marqueDebut, marqueFin);
console.log(`${nom}: ${mesure.duration.toFixed(1)}ms`);
return result;
}
// Utilisation
const data = await mesurer("charger-dashboard", () => chargerDashboard(42));
Comparer sequentiel vs parallèle
javascriptasync function benchmark() {
const urls = ["/api/a", "/api/b", "/api/c", "/api/d", "/api/e"];
// Sequentiel
const t1 = performance.now();
for (const url of urls) {
await fetch(url);
}
const sequential = performance.now() - t1;
// Parallele
const t2 = performance.now();
await Promise.all(urls.map(url => fetch(url)));
const parallel = performance.now() - t2;
console.log(`Sequentiel: ${sequential.toFixed(0)}ms`);
console.log(`Parallele: ${parallel.toFixed(0)}ms`);
console.log(`Gain: ${((1 - parallel / sequential) * 100).toFixed(0)}%`);
}
Server Timing
Cote serveur, tu peux exposer les metriques de timing via le header Server-Timing, visible dans les DevTools du navigateur :
javascript// Express middleware
app.use((req, res, next) => {
const start = process.hrtime.bigint();
res.on("finish", () => {
const duration = Number(process.hrtime.bigint() - start) / 1_000_000;
res.setHeader("Server-Timing", `total;dur=${duration.toFixed(1)}`);
});
next();
});
Résumé
La performance async se joue sur quelques axes principaux. Le coût de création d'une Promise est negligeable unitairement mais s'accumule dans les boucles. L'anti-pattern waterfall (des awaits sequentiels sur des opérations independantes) est le problème le plus courant et le plus facile a corriger avec Promise.all. Le batching réduit le nombre de requêtes réseau, le connection pooling optimise leur réutilisation, et le prefetching lance les requêtes avant que l'utilisateur en ait besoin. Le lazy loading via import() dynamique réduit la taille du bundle initial. Et pour tout ca, la mesure (Performance API, Server-Timing) est indispensable : sans metriques, tu optimises a l'aveugle.
Retrouve d'autres articles sur la performance JavaScript sur paltemps.fr.
Navigation : Precedent : 16 - Debugger l'asynchrone | Suivant : 18 - Glossaire
Sources
- MDN - Performance API par Mozilla
- web.dev - Optimize long tasks par Google
- V8 blog - Fast async par V8 team