import { Suspense } from 'react';
import { Header, TableSkeleton, WidgetSkeleton } from '@/components/ui';
import { FastTable, SlowAnalyticsWidget } from '@/components/dashboard';
// Pagina randează shell-ul instant, fără să aștepte widget-ul lent
export default function DashboardPage() {
return (
<main className="p-6 space-y-6">
<Header title="Overview" />
{/* Tabelul rapid - se încarcă primii */}
<Suspense fallback={<TableSkeleton />}>
<FastTable />
</Suspense>
{/* Widget-ul lent - face streaming peste conexiunea deja deschisă */}
<Suspense fallback={<WidgetSkeleton />}>
<SlowAnalyticsWidget />
</Suspense>
</main>
);
}Am văzut mulți colegi care pun un singur loading.tsx în folderul de rute din App Router și consideră că au rezolvat problematica de UX. Anul trecut, la un refactoring pe un dashboard cu peste 10k utilizatori zilnici, abordarea asta ne-a distrus LCP-ul (Largest Contentful Paint) și a dat senzația că toată aplicația se mișcă greoi, deși backend-ul răspundea în sub 100ms pe majoritatea endpoint-urilor.
Haideti să separăm puțin conceptele, pentru că loading.tsx și <Suspense>-ul manual rezolvă probleme diferite, chiar dacă sub capotă Next.js folosește tot Suspense pentru ambele.
Ce face de fapt loading.tsx sub capotă
Când trântești un loading.tsx într-un director de rută, Next.js împachetează automat tot fișierul page.tsx într-un <Suspense fallback={<Loading />}>. Asta înseamnă o decizie de tip totul-sau-nimic.
Dacă ai o pagină cu trei componente — un header rapid, un tabel principal și un sidebar cu recomandări —, iar sidebar-ul face un query lent de SQL de 1.2 secunde, utilizatorul va vedea un ecran alb cu spinner (sau skeleton-ul global) timp de 1.2 secunde. Abia după ce s-a rezolvat ultimul await din pagină, HTML-ul complet este trimis sau afișat. Ai pierdut tot avantajul redării progresive.
Granularitate cu Suspense și Streaming SSR
Soluția pe care am aplicat-o a fost eliminarea loading.tsx-ului global din rutele complexe și izolarea fetch-urilor direct în React Server Components, învăluite în boundary-uri <Suspense> locale.
Prin Streaming SSR, serverul Node.js trimite imediat către browser shell-ul HTML inițial (Header, Layout, Navigație). În timp ce conexiunea HTTP rămâne deschisă, React procesează în paralel componentele asincrone. Pe măsură ce un fetch se termină, bucata de HTML respectivă este trimisă pe fir sub formă de chunk-uri scriptate și injectată fix în locul skeleton-ului.
La proiectul menționat mai sus, mutarea fetch-ului de analitice lente într-o componentă separată cu Suspense propriu ne-a scos un câștig uriaș: TTFB-ul perceptibil a scăzut de la 1.3 secunde la 85ms, iar userul putea deja să interacționeze cu meniul și tabelul rapid în timp ce widget-ul greoi încă se încărca.
Trade-off-uri reale: Când folosești fiecare?
Nu înseamnă că loading.tsx e inutil. E o unealtă excelentă pentru tranziții majore de navigație între pagini complet diferite (ex: trecerea de la /dashboard la /settings). N-are sens să complici codul cu zece boundary-uri de Suspense pe o pagină simplă de setări unde ai un singur form.
Unde devine problematic Suspense-ul granular:
- Cumulative Layout Shift (CLS): Dacă skeleton-urile tale nu respectă exact dimensiunile componentelor finale, pagina va «sări» agresiv când vin datele. Am pățit să stricăm scorul de Core Web Vitals exact din cauză că skeleton-ul avea 200px înălțime, iar widget-ul randat avea 450px.
- Waterfall-uri ascunse: Dacă pui componente asincrone una în alta, creezi fără să vrei un waterfall de rețea. Fetch-ul din componenta copil nu începe până nu se termină fetch-ul din componenta părinte.
Trageți fetch-urile în paralel unde se poate sau ridicați-le în cel mai apropiat părinte comun folosind Promise.all înainte să pasați promise-urile către Suspense.
Voi cum gestionați skeleton-urile pe proiectele mari — lăsați loading.tsx per rută sau spargeți totul în widget-uri cu Suspense separat?