import { Suspense } from 'react';
// Componentă async ce rulează izolat pe server
async function AnalyticsWidget() {
const res = await fetch('https://api.internal/analytics', {
next: { revalidate: 60 }
});
const data = await res.json();
return <div className="p-4 border rounded-lg">{data.metrics}</div>;
}
export default function DashboardPage() {
return (
<main className="p-6 grid grid-cols-2 gap-4">
<section>
<h1 className="text-xl font-bold">Statistici</h1>
{/* Shell-ul paginii se rendează instant. AnalyticsWidget vine prin streaming */}
<Suspense fallback={<div className="h-32 animate-pulse bg-slate-800 rounded-lg" />}>
<AnalyticsWidget />
</Suspense>
</section>
</main>
);
}Am trecut prin trei refactorizări masive de la lansarea App Router-ului în Next.js și încă văd echipe blocate în aceeași dilemă: trântim un loading.tsx în folder sau punem <Suspense> manual? Dacă ai un dashboard stufos sau o pagină de e-commerce și vrei să scazi TTFB-ul de la 1.2s la sub 300ms, diferența asta de tehnică îți schimbă complet aplicația.
Ce face loading.tsx de fapt sub capotă?
Mulți developeri cred că loading.tsx este o magie specifică Next.js. În realitate, e doar o convenție de fișiere care generează un wrapper automat. Când trântești un loading.tsx într-un director din app/, framework-ul împachetează tot page.tsx-ul din acel segment într-o graniță de <Suspense fallback={<Loading />}>.
Problema? Blochezi toată renderarea paginii până când ultimul await din top-level-ul din page.tsx se rezolvă. Pe o aplicație B2B cu 12k useri la care am lucrat anul trecut, aveam un widget de Analytics care răspundea în 900ms și un feed de notificări care venea în 50ms. Din cauza lui loading.tsx la nivel de pagină, utilizatorul se uita la un skeleton alb timp de aproape o secundă întreagă, deși 80% din pagină era deja pregătită pe server.
Când treci la <Suspense> granular și Streaming SSR
Aici intervine streaming-ul prin HTTP (Node streams sau Web Streams API). React trimite primul HTML — adică layout-ul static, shell-ul aplicației și fallback-urile de Suspense — instant către browser. Conexiunea rămâne deschisă, iar serverul transmite scripturi inline cu payload-ul HTML pe măsură ce promise-urile din fundal se rezolvă.
În loc să faci await la nivel de pagină (lucru care blochează tot route-ul), spargi fetch-urile în componente asincrone separate, fiecare înfășurată în propriul ei <Suspense>. Pagina devine o simplă carcasă sincronă, iar widget-urile grele își încarcă datele izolat.
Compromisul: Waterfall-uri ascunse și CLS
Nimic nu vine gratis. Suspense e genial pentru UX, dar îți poate strica performanța dacă ești neatent.
Primul risc masiv e waterfall-ul de fetch-uri. Dacă pui o componentă async în interiorul altei componente async (părinte-copil), serverul le va executa secvențial. Am pățit-o la un proiect unde un <UserProfile> făcea fetch la datele de cont, iar în interiorul lui un <UserOrders> făcea fetch la comenzi. Răspunsul total a sărit de la 400ms la 1.1s fără să ne dăm seama. Soluția e să aduci componentele pe același nivel ierarhic sau să declanșezi fetch-urile în paralel.
Al doilea risc este Cumulative Layout Shift (CLS). Dacă fallback-ul tău de Suspense are o înălțime de 50px, iar componenta încărcată ocupă 400px, pagina va «sări» agresiv când vine stream-ul de date. Skeleton-ul trebuie să aibă dimensiuni identice cu layout-ul final, altfel scorul Lighthouse se duce direct în jos.
Regula mea empirică
Folosesc loading.tsx doar pentru layout-uri globale sau tranziții majore între rute, unde vreau o stare vizuală imediată la nivel de navigare. Pentru orice altceva din interiorul paginii — widget-uri, tabele, feed-uri — folosesc <Suspense> granular direct în jurul componentelor async din server.
Voi cum gestionați cazurile în care aveți 4-5 API-uri terțe pe aceeași pagină? Mergeți pe streaming direct sau preferați s-aduceți totul într-un BFF (Backend For Frontend)?