Arrêter l’utilisation des chutes d’eau dans SvelteKit avec les fonctions de chargement parallèle
Découvrez pourquoi les attentes séquentielles ralentissent les pages, et comment SvelteKit utilise les fonctions de chargement, Promise.all, des appels prudents à parent() ainsi que des promesses en flux pour éliminer ces problèmes de ralentissement.
Une page peut sembler lente même sur une connexion rapide et un backend performant, lorsque ses requêtes s’exécutent les unes après les autres plutôt que simultanément. Chaque aller-retour attend la fin du précédent, de sorte que le temps nécessaire pour afficher le premier contenu pertinent devient la somme de toutes les requêtes plutôt que la durée de celle qui est la plus lente. Ce guide explique d’où proviennent ces séquences en cascade et montre comment structurer les fonctions load de SvelteKit afin que les données essentielles arrivent en parallèle, que le chargement du layout et de la page ne s’entravent pas, et que les données lentes et non essentielles soient chargées après le premier rendu.
À quoi ressemble une séquence de requêtes en cascade
Ouvrez le panneau Réseau dans les outils de développement du navigateur sur un tableau de bord React ou Vue typique généré côté client, et chargez une page qui nécessite plusieurs ensembles de données. Les barres représentent souvent un escalier : la première requête est terminée avant que la deuxième ne commence, et la troisième attend la deuxième. Chaque étape implique un aller-retour complet sur le réseau.
Le code à l’origine de cet escalier semble généralement inoffensif. Un composant est chargé dans le navigateur, puis découvre ce dont il a besoin. À l’intérieur d’un effet ou d’un hook de chargement, on trouve des instructions comme const user = await getUser(), puis const posts = await getPosts(), puis const comments = await getComments(). Trois lignes, trois appels à await, rien d’apparemment anormal.
Le problème réside dans la signification de await. Il suspend la fonction jusqu’à ce que la promesse soit résolue, de sorte que la requête posts ne peut même pas être envoyée tant que la réponse user n’est pas complètement reçue, et comments attend à son tour posts. Supposons que chaque appel prenne environ 300 ms, en incluant le DNS, le TLS, le traitement par le serveur et le transfert des données. La page n’a donc rien d’utile à afficher pendant environ la durée totale des trois opérations, et non seulement pendant celle de l’opération la plus longue.
Rien ne plante, aucun test échoue, et chaque composant fait exactement ce qui lui a été demandé. Les utilisateurs se contentent d’observer un indicateur de chargement pendant environ trois fois plus longtemps que nécessaire. Cet aspect invisible est ce qui rend les architectures en cascade si courantes.
Pourquoi les architectures en cascade continuent de se répandre
Trois habitudes courantes en sont la cause principale :
- Chaque composant récupère ses propres données. Dans les applications rendues côté client, un composant parent doit souvent terminer son chargement avant qu’un enfant ne puisse être initialisé, et l’enfant ne peut pas lancer sa propre requête tant qu’il n’a pas reçu une propriété du parent. L’arbre des composants devient alors une chaîne de requêtes.
- Attente ligne par ligne par habitude. Les instructions
awaitséquentielles se lisent facilement, ce qui explique précisément pourquoi elles masquent le fait qu’elles sérialisent des requêtes qui ne dépendaient jamais les unes des autres. - Aucune vue unique des besoins en données d’une page n’est nécessaire. Lorsque le code de récupération des données est réparti sur trois fichiers, il est difficile de se rendre compte que toutes les trois requêtes auraient pu être lancées en même temps.
Rédiger du code plus rapidement ne sert à rien. Ce qui aide, c’est de déplacer l’opération de récupération en un autre endroit et à un autre moment : avant même qu’un composant n’existe, en un seul point coordonné. Dans SvelteKit, ce point est la fonction load. Pour une analyse neutre par rapport au framework des mêmes options de concurrence, consultez la comparaison du blog sur Promise.all, Promise.race et attendre séquentiellement.
Comment les fonctions load de SvelteKit modifient la configuration par défaut
Toute route de SvelteKit peut exporter une fonction load qui s’exécute avant que son composant de page ne soit rendu. Les composants n’ont plus besoin de récupérer des données après leur montage ; la route collecte tout d’abord et le transmet à la page sous forme d’une seule propriété data.
Il existe deux variantes, et le choix est important :
- Charge universelle dans
+page.jss’exécute sur le serveur lors du rendu côté serveur et dans le navigateur lors de la navigation côté client. Elle convient aux appels vers des API publiques et au retour de valeurs non serialisables. - Charge serveur dans
+page.server.jss’exécute uniquement sur le serveur. Son code n’est jamais envoyé au navigateur, ce qui lui permet d’utiliser en toute sécurité des clients de base de données, des variables d’environnement privées et des tokens d’authentification.
Les layouts suivent le même schéma avec +layout.js et +layout.server.js.
Dans la plupart des applications réelles, tout ce qui interagit avec une base de données, un service interne ou des informations secrètes doit être placé dans +page.server.js. Sa forme minimale est la suivante :
// src/routes/dashboard/+page.server.js
export async function load({ fetch }) {
const res = await fetch('/api/user');
const user = await res.json();
return {
user
};
}
Il y a trois points importants à retenir dès le début concernant ce fragment de code.
La fonction de charge se termine avant que la page ne soit rendue
Lorsque +page.svelte commence à s’afficher, user est déjà un objet ordinaire. Il n’y a ni crochet de montage, ni indicateur de chargement, ni moment où la valeur est undefined.
Utilisez le fetch fourni par SvelteKit
L’argument fetch n’est pas le global. SvelteKit propose une version améliorée qui accepte des URL relatives telles que /api/user, transmet les cookies et en-têtes de la requête entrante lors du rendu côté serveur, et, lorsqu’il vise une autre route au sein de la même application, appelle directement ce gestionnaire sans effectuer de requête HTTP réelle. Importer un fetch global fait perdre tout cela.
La valeur de retour devient la propriété data de la page
Tout ce que la fonction renvoie est mis à disposition du composant de page sous la forme de data. Dans un composant Svelte 5, on le lit avec let { data } = $props(); (le code Svelte 4 plus ancien utilise export let data;) puis on affiche data.user.name dans le markup.
Le modèle mental est simple : obtenir les données en premier, afficher ensuite le résultat, et recevoir des valeurs résolues plutôt que des promesses à gérer au sein du composant.
Cependant, déplacer l’opération de récupération dans la fonction load ne supprime pas automatiquement ce modèle en cascade. Trois appels à await consécutifs à l’intérieur de load reconstituent la même séquence d’opérations sur le serveur. La véritable solution vient ensuite.
Démarrer toutes les requêtes indépendantes en même temps
La solution consiste à lancer immédiatement toutes les requêtes indépendantes et à attendre leur exécution en groupe :
// src/routes/dashboard/+page.server.js
export async function load({ fetch }) {
// Kick off all three requests immediately - none of them
// are awaited yet, so none of them block each other.
const userPromise = fetch('/api/user').then(r => r.json());
const postsPromise = fetch('/api/posts').then(r => r.json());
const commentsPromise = fetch('/api/comments').then(r => r.json());
// NOW wait for all of them to finish, in parallel.
const [user, posts, comments] = await Promise.all([
userPromise,
postsPromise,
commentsPromise
]);
return { user, posts, comments };
}
Pourquoi c’est plus rapide
Le facteur décisif est l’intervalle entre l’envoi d’une requête et l’attente de son résultat. Appeler fetch(...) envoie immédiatement la requête ; il n’attend pas un await. L’ajout de .then() ne fait que décrire ce qu’il convient de faire avec la réponse une fois qu’elle arrive enfin. Ainsi, dans le code ci-dessus :
- la première ligne envoie la requête
/api/user; - la deuxième ligne envoie
/api/poststandis que la première est encore en cours d’exécution ; - la troisième ligne envoie
/api/commentstandis que les deux autres sont encore en cours d’exécution ; - seul
Promise.alls’arrête réellement, et il reprend dès que le plus lent des trois est terminé.
Le temps total passe de la somme des trois requêtes à environ la durée de la plus lente d’entre elles. Les requêtes, le serveur et les données restent inchangés ; seul le placement de l’instruction await est modifié. Dans un exemple où chaque requête prend environ 300 ms plus quelques surcoûts, cela permet à la page de redevenir utilisable en environ 360 ms au lieu de 960 ms, et l’écart s’agrandit sur des réseaux lents ou avec une API très sollicitée.
Rares sont les modifications apportées à une page gourmande en données qui offrent autant d’avantages avec si peu d’efforts. Aucune bibliothèque à ajouter et aucune architecture à redessiner ; il suffit de réordonner quelques lignes.
À noter avec Promise.all
Promise.all est rejeté dès que l’une de ses promesses est rejetée. Si une requête comments qui échoue ne doit pas faire tomber toute la page, utilisez Promise.allSettled ou attribuez à chaque promesse son propre .catch() qui retourne une valeur de secours. Notez également que r.json() ne vérifie pas r.ok ; une réponse 404 ou 500 contenant un corps d’erreur en JSON sera analysée et renvoyée comme s’il s’agissait de données, il faut donc vérifier l’état lorsque la correction dépend de celui-ci.
L’cascade cachée entre les layouts et les pages
Les développeurs qui ont appris le truc de Promise.all mettent souvent en place un deuxième type de cascade, celle qui s’étend sur plusieurs fichiers : la manière dont le chargement des données d’un layout interagit avec celui de la page qui se trouve en dessous.
Déjà prêt à l’emploi, SvelteKit exécute simultanément les fonctions load de +layout.server.js et +page.server.js, tout comme dans l’exemple parallèle ci-dessus. La solution consiste à utiliser parent(), qui permet à la fonction load d’une page d’accéder aux données retournées par les layouts qui la précèdent. C’est parfois exactement ce dont on a besoin, par exemple lorsque la requête de la page nécessite un ID d’utilisateur que seuls les layouts peuvent fournir. Cependant, si elle est appelée trop tôt, cela entraîne la sérialisation de tout ce qui suit :
// src/routes/dashboard/+page.server.js
// BAD: this creates a waterfall between the layout and the page,
// even if `posts` doesn't actually need anything from `parent()`.
export async function load({ parent, fetch }) {
const { user } = await parent(); // blocks here until layout's load finishes
const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json());
return { posts };
}
Si la requête des articles a réellement besoin de user.id, cet ordre est inévitable et tout à fait acceptable ; il s’agit d’une dépendance réelle. Le problème apparaît lorsque la page a également besoin de données qui n’ont rien à voir avec le layout. L’attente de parent() sur la première ligne retarde toutes les instructions suivantes, y compris celles qui sont indépendantes, jusqu’à ce que le layout soit terminé.
La solution consiste à changer l’ordre : commencer par les tâches indépendantes et n’attendre parent() que là où sa valeur est nécessaire.
// src/routes/dashboard/+page.server.js
// GOOD: independent work starts immediately; parent() is only
// awaited once we actually need the merged result.
export async function load({ parent, fetch }) {
const commentsPromise = fetch('/api/comments').then(r => r.json());
const { user } = await parent(); // runs concurrently with the fetch above
const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json());
const comments = await commentsPromise;
return { user, posts, comments };
}
Ici, la requête des commentaires est envoyée avant que la page n’attende le layout, ce qui provoque un chevauchement des opérations. La requête des articles doit toujours attendre user.id, ce qui est correct, et la promesse relative aux commentaires a généralement déjà été résolue au moment où son exécution est attendue à la fin.
Règle générale : traitez await parent() comme n’importe quel autre await. Placez-le précisément là où la valeur est nécessaire, jamais de manière réflexe en haut de la fonction, et lancez toute requête qui ne dépend pas des données parentales avant lui.
Débit de données non critiques
Promise.all n’est pas toujours la solution idéale. Si une requête est lente et que ses données ne sont pas nécessaires pour ce que l’utilisateur voit en premier, attendre cette requête rendra toute la page aussi lente que sa partie la moins importante. Sur une page de produit, le nom, le prix et les images sont critiques ; la section des avis, située plusieurs écrans plus loin, ne l’est pas.
Dans ce cas, SvelteKit prend en charge le streaming. Retournez une promesse depuis un load serveur sans l’attendre, et SvelteKit enverra immédiatement la page rendue, puis transmettra la valeur de la promesse au navigateur une fois qu’elle sera résolue.
// src/routes/product/[id]/+page.server.js
export async function load({ fetch, params }) {
// Critical: awaited, blocks the initial render - but it's fast.
const product = await fetch(`/api/product/${params.id}`).then(r => r.json());
// Non-critical: NOT awaited. This is handed to the page as a
// pending Promise, and SvelteKit streams it in once it resolves.
const reviewsPromise = fetch(`/api/product/${params.id}/reviews`).then(r => r.json());
return {
product, // resolved value
reviews: reviewsPromise // still-pending promise
};
}
Le produit est attendu car sa mise en page initiale en a besoin et la requête est rapide. La requête concernant les avis est lancée mais non attendue, de sorte que la page reçoit une promesse en attente sous data.reviews.
Sur la page, le bloc {#await} de Svelte gère ces deux états. À l’intérieur de {#await data.reviews}, on affiche un placeholder léger comme la ligne « Chargement des avis... », et dans la branche {:then reviews}, on itère sur les résultats. Une branche optionnelle {:catch error} affiche un message en cas d’échec de la requête.
L’utilisateur voit presque immédiatement les détails du produit, la zone des avis affiche un petit indicateur de chargement, et les vrais avis le remplacent dès leur arrivée. Il n’y a ni code supplémentaire de récupération côté client ni hook de montage.
Précautions liées au streaming
Tenez compte de ces contraintes :
- Le streaming fonctionne grâce aux fonctions de chargement du serveur. La promesse est créée sur le serveur et diffusée depuis
+page.server.jsou+layout.server.js. Un chargement universel de+page.jspeut également retourner une promesse, mais elle n’est pas diffusée depuis le serveur de la même manière. - Votre adaptateur et votre hébergement doivent prendre en charge les réponses diffusées. Les adaptateurs Node et Vercel le font, tout comme la plupart des plateformes modernes, mais vérifiez-le pour les cibles moins courantes. Les proxies qui buffernt les réponses peuvent également annuler silencieusement cet avantage.
- Gérez les rejets. Une promesse diffusée qui échoue sans branche
{:catch}ni gestionnaire.catch()peut provoquer un rejet non géré sur le serveur. Prévoyez une solution de secours pour les promesses non critiques.
Le cycle de vie complet d’une requête
Avec des requêtes critiques en parallèle, une utilisation intentionnelle de parent() et la diffusion des données non essentielles, une requête vers une route lourde en données suit ces étapes :
- Le navigateur demande l’accès à la route.
- SvelteKit exécute les fonctions de mise en page et de chargement
loadde la route sur le serveur. - Toutes les données indépendantes, telles que l’utilisateur, les publications et les commentaires, sont récupérées en parallèle.
Comparez cela avec la version côté client depuis le début. Au lieu que le navigateur effectue trois allers-retours successifs après le chargement de la page, le serveur les effectue en parallèle avant même d’envoyer quoi que ce soit, avec généralement une latence bien plus faible par rapport aux sources de données que celle du navigateur de l’utilisateur.
Liste de contrôle avant déploiement pour les routes à forte charge de données
Vérifiez ces points avant de déployer une route nécessitant plusieurs données :
- Les données nécessaires à l’affichage initial sont-elles récupérées dans un composant au moment de son montage ? Déplacez-les dans une fonction
load, généralement une fonction serveur. - Y a-t-il plusieurs appels à
awaitindépendants consécutifs à l’intérieur deload? Lancez d’abord toutes les requêtes, puis attendez leur résultat ensemble à l’aide dePromise.allouPromise.allSettled. await parent()est-il la première ligne de la fonctionloadd’une page ? Déplacez-le là où les données du parent sont réellement utilisées, et lancez d’abord les requêtes non liées avant lui.- Y a-t-il des données qui ne sont pas pertinentes pour le premier affichage ? Retournez-les sous forme de promesse non attendue et affichez-les avec
{#await}, en incluant une branche de gestion des erreurs.
fetch provient-il des arguments load ? Seule cette version traite correctement les URL relatives et transmet les cookies lors du rendu côté serveur.En résumé
Les cascades d’appels ne sont pas la preuve d’un code négligent. Elles résultent naturellement du fait que la récupération de données est dispersée dans l’arborescence des composants. Les fonctions load de SvelteKit sont moins importantes du fait qu’elles s’exécutent sur le serveur, et plus importantes parce qu’elles rassemblent toutes les requêtes nécessaires à une page en un seul endroit visible, ce qui permet de déterminer lesquelles doivent s’exécuter ensemble, lesquelles dépendent réellement des autres, et lesquelles peuvent arriver plus tard. L’habitude à adopter est simple : cesser d’attendre par réflexe et commencer à attendre intentionnellement. Appliquée à chaque route, cela permet aux utilisateurs d’économiser du temps, même sur des connexions lentes ou avec un backend surchargé.
Lectures complémentaires
- Corréler les journaux entre des appels asynchrones à l’aide d’AsyncLocalStorage — Découvrez comment Node.js AsyncLocalStorage suit le contexte par requête, comme requestId, au-delà des limites d’await, sans devoir le transmettre manuellement à travers chaque fonction.
- Décharger le travail intensif en CPU dans Node.js grâce à worker_threads et Pools — Apprenez comment les worker_threads de Node.js maintiennent le cycle d’événements réactif : création de travailleurs, communication demande-réponse, buffers transférables, pools, ainsi que leurs pièges.