Next.jsSuspenseStreamingApp RouterPerformance

Streaming et Suspense dans Next.js : un temps de chargement perçu divisé par deux

Maîtrisez le streaming SSR, Suspense et loading.tsx dans Next.js App Router pour afficher votre page progressivement et améliorer l'UX perçue.

AMAlexis Mouchon9 min de lecture

Vous avez une page Next.js qui agrège plusieurs sources de données : un produit, ses avis, des recommandations. Tout est server-rendered, tout est propre, mais l'utilisateur reste sur un écran blanc tant que la requête la plus lente n'a pas répondu. Une seconde, parfois deux. C'est exactement le scénario que le streaming et React Suspense viennent résoudre dans l'App Router.

L'idée est simple : au lieu d'attendre que toute la page soit prête avant de l'envoyer, on l'envoie par morceaux, dès que chaque morceau est disponible. L'utilisateur voit la structure et le contenu rapide immédiatement, et les zones lentes apparaissent au fur et à mesure. Voyons comment ça marche concrètement.

Pourquoi le rendu serveur classique bloque

Avec le rendu serveur traditionnel (et c'est le comportement par défaut d'un Server Component qui await ses données), le serveur exécute toute la logique de la page, génère le HTML complet, puis l'envoie. Le navigateur ne reçoit rien avant la fin.

Le problème vient des dépendances en cascade. Imaginez une page produit :

// app/produit/[id]/page.tsx — version bloquante
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  const product = await getProduct(id);       // ~100 ms
  const reviews = await getReviews(id);        // ~800 ms (lent)
  const recommended = await getRecommended(id); // ~400 ms

  return (
    <main>
      <ProductHeader product={product} />
      <ReviewList reviews={reviews} />
      <Recommendations items={recommended} />
    </main>
  );
}

Ici, le await sur getReviews bloque tout : même si l'en-tête produit est prêt en 100 ms, l'utilisateur ne voit rien avant ~1,3 seconde (les requêtes étant séquentielles). Le temps de chargement perçu est désastreux, alors que la donnée la plus importante était disponible presque instantanément.

Le streaming : envoyer le HTML par morceaux

Le streaming repose sur un mécanisme HTTP : la réponse est envoyée en plusieurs chunks plutôt qu'en un seul bloc. Next.js, couplé à React 18+, sait diffuser le HTML progressivement. Concrètement, le serveur envoie d'abord le « squelette » de la page (ce qui est prêt), puis pousse le reste via le même flux dès que les données arrivent.

Le déclencheur de ce comportement, c'est Suspense. Tout ce que vous enveloppez dans une frontière <Suspense> peut être streamé indépendamment : Next.js envoie un fallback à la place, puis remplace ce fallback par le contenu réel une fois prêt — sans rechargement, sans appel client supplémentaire.

Découper la page avec Suspense

Reprenons la page produit, mais cette fois en isolant chaque zone lente derrière sa propre frontière Suspense. La clé : ne plus faire les await dans le composant de page, mais les déléguer à des sous-composants asynchrones.

// app/produit/[id]/page.tsx — version streamée
import { Suspense } from "react";

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  // Donnée critique et rapide : on l'attend (elle structure la page)
  const product = await getProduct(id);

  return (
    <main>
      <ProductHeader product={product} />

      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews id={id} />
      </Suspense>

      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations id={id} />
      </Suspense>
    </main>
  );
}

Les sous-composants encapsulent leur propre récupération de données :

// app/produit/[id]/Reviews.tsx
async function Reviews({ id }: { id: string }) {
  const reviews = await getReviews(id); // les 800 ms vivent ici, isolés
  return <ReviewList reviews={reviews} />;
}

export default Reviews;

Résultat : ProductHeader s'affiche en ~100 ms. Pendant que l'utilisateur lit déjà le titre et le prix, les avis et les recommandations se chargent en parallèle, chacun remplaçant son skeleton dès qu'il est prêt. Le temps perçu passe de ~1,3 s à ~100 ms pour le contenu utile.

Point important : les deux frontières Suspense étant sœurs, leurs requêtes partent en parallèle. C'est là que se gagne le temps — on a transformé une cascade séquentielle en chargement concurrent.

loading.tsx : le Suspense automatique des routes

L'App Router propose un raccourci pour streamer une route entière : le fichier loading.tsx. Quand vous le placez dans un segment, Next.js enveloppe automatiquement le page.tsx correspondant dans une frontière Suspense, en utilisant votre loading.tsx comme fallback.

// app/dashboard/loading.tsx
export default function Loading() {
  return <DashboardSkeleton />;
}

Dès que l'utilisateur navigue vers /dashboard, le squelette s'affiche instantanément pendant que page.tsx récupère ses données côté serveur. C'est l'approche idéale pour le chargement d'une route complète. Pour un contrôle plus fin à l'intérieur d'une même page, on revient au <Suspense> manuel, comme dans l'exemple produit.

La règle que j'applique : loading.tsx pour la transition entre routes, <Suspense> explicite pour les zones lentes au sein d'une page déjà affichée.

Soigner les fallbacks : skeletons plutôt que spinners

Le streaming n'a de valeur que si le fallback est bien pensé. Un spinner centré qui tourne donne une impression de page « en attente », alors qu'un skeleton qui reproduit la mise en page finale donne une impression de page « presque là ». La différence sur le ressenti est énorme, et elle évite aussi le décalage de mise en page (layout shift) qui pénalise le CLS, l'un des Core Web Vitals.

// app/produit/[id]/ReviewsSkeleton.tsx
export function ReviewsSkeleton() {
  return (
    <div className="space-y-4" aria-hidden="true">
      {Array.from({ length: 3 }).map((_, i) => (
        <div key={i} className="rounded-lg border border-gray-200 p-4">
          <div className="mb-2 h-4 w-1/3 animate-pulse rounded bg-gray-200" />
          <div className="h-3 w-full animate-pulse rounded bg-gray-100" />
          <div className="mt-2 h-3 w-2/3 animate-pulse rounded bg-gray-100" />
        </div>
      ))}
    </div>
  );
}

Réservez la même hauteur et la même structure que le contenu réel : le bloc d'avis occupe trois cartes avant comme après le chargement, donc rien ne « saute » quand la donnée arrive.

Les pièges à connaître

Quelques erreurs reviennent souvent quand on adopte le streaming.

Attendre la donnée lente dans le composant de page. Si vous laissez un await getReviews() directement dans page.tsx, vous bloquez tout le streaming, même si le reste est entouré de Suspense. La donnée lente doit vivre dans un composant à l'intérieur de la frontière Suspense, jamais au-dessus.

Oublier que Suspense se déclenche sur la promesse. Un Server Component asynchrone suspend naturellement. Mais côté client, pour suspendre, il faut une source compatible (par exemple use() sur une promesse, ou une lib de data-fetching qui supporte Suspense). N'enveloppez pas un useEffect dans Suspense en espérant un fallback : ça ne fonctionnera pas.

Le streaming et les codes de statut HTTP. Une fois que le serveur a commencé à streamer (les premiers octets sont partis), il ne peut plus changer le code de réponse ni les en-têtes. Si une zone streamée échoue, gérez l'erreur avec un error.tsx ou un fallback local plutôt qu'en comptant sur un statut 500 global.

Trop de frontières Suspense. Découper chaque petit élément en sa propre frontière ajoute de l'overhead et fait clignoter la page. Regroupez par zone logique : un bloc « avis », un bloc « recommandations », pas un Suspense par étoile de notation.

Mesurer le gain

Le streaming améliore surtout deux métriques : le TTFB (Time To First Byte, qui baisse car le serveur envoie le squelette immédiatement) et le FCP / LCP perçu. Pour vérifier l'effet réel, ouvrez l'onglet Réseau des DevTools et observez la réponse du document : en streaming, vous verrez le HTML arriver en plusieurs temps plutôt qu'en un seul bloc à la fin.

Pour aller plus loin sur l'optimisation globale, le streaming se combine très bien avec une bonne stratégie de cache et un découpage propre entre Server et Client Components.

Conclusion

Le streaming SSR avec Suspense est l'un des leviers les plus rentables de l'App Router : peu de code, aucun changement d'architecture lourd, et un gain immédiat sur le ressenti utilisateur. Le réflexe à adopter : afficher tout de suite ce qui est rapide, isoler ce qui est lent derrière une frontière Suspense, et soigner les skeletons pour éviter tout layout shift. Vous transformez une page qui « attend » en une page qui « se construit sous les yeux » de l'utilisateur — et c'est exactement cette impression de vitesse qui compte.

Pour creuser les sujets connexes, vous pouvez lire mes articles sur les performances Next.js et les Core Web Vitals, la frontière Server / Client Components et les stratégies de cache de Next.js 16.

Vous avez un projet web qui mérite d'être à la fois rapide et agréable à utiliser ? N'hésitez pas à me contacter pour en discuter — par formulaire ou directement à contact@alexis-mouchon.fr.