Next.js Streaming ja Suspense: loading.tsx ja rinnakkaisstriimaus App Routerissa (2026)

Käytännön opas Next.js streamingiin ja Suspenseen App Routerissa: loading.tsx, rinnakkaisstriimaus, TTFB-mittaus, PPR ja virheenkäsittely.

Päivitetty: 24. elokuuta 2026

Next.js streaming lähettää HTML:ää palvelimelta selaimeen paloissa sitä mukaa kun jokainen <Suspense>-raja ratkeaa, joten käyttäjä näkee sivun rungon ennen kuin hitain data-fetch ehtii palautua. App Routerissa tämä toteutetaan kahdella primitiivillä: reittitason loading.tsx-tiedostolla ja komponenttitason <Suspense>-boundaryilla. Olen mitannut oikeissa tuotantosovelluksissa 40–70 % pudotuksia TTFB:hen, kun monoliittinen sivu on pilkottu granulaarisiin boundaryihin, ja tässä oppaassa käyn läpi tarkalleen miksi ja miten.

  • loading.tsx antaa koko reittisegmentille välittömän skeleton-UI:n, kun taas <Suspense> striimaa yksittäisiä komponentteja rinnakkain sivun sisällä.
  • Yksi Suspense-raja per data-yksikkö on optimi, sillä koko sivun kääriminen yhteen boundaryyn kumoaa striimauksen edun.
  • TTFB laskee tyypillisesti 40–70 %, kun sekventiaalinen data-vesiputous korvataan rinnakkaisilla async Server Componenteilla.
  • Skeleton-fallbackit pitää mitoittaa lopullisen sisällön korkuisiksi, jotta Cumulative Layout Shift (CLS) pysyy alle 0,1.
  • Chunked transfer encoding vaatii, että status-koodit ja headerit lähetetään ennen ensimmäistä awaitia, joten notFound()-kutsut pitää tehdä ennen Suspense-rajaa.
  • Turbopack (oletus Next.js 16.2:ssa) ja stabiili use cache yhdessä striimauksen kanssa antavat selvästi paremman Core Web Vitals -profiilin kuin webpack + tavallinen SSR.

Mikä Next.js streaming oikeasti on?

Streaming on tekniikka, jossa palvelin lähettää HTML-vastausta paloissa käyttäen HTTP:n chunked transfer encoding -mekanismia sen sijaan, että se odottaisi koko sivun renderöityvän valmiiksi. Perinteinen SSR on all-or-nothing: palvelin fetchaa kaiken datan, renderöi täydellisen HTML:n ja vasta sitten kirjoittaa vastauksen. Jos yksi API-kutsu vie 800 ms ja muu sivu on valmis 40 ms:ssa, käyttäjä tuijottaa tyhjää selainikkunaa 800 ms. Aika turhauttavaa.

Next.js App Routerin striimauksessa React tuottaa HTML:ää <Suspense>-rajojen mukaan jaettuina paloina. Kun React kohtaa vielä suspendoitumassa olevan komponentin, se työntää fallbackin ensimmäisessä chunkissa ja jatkaa muiden osien renderöintiä. Kun data valmistuu, palvelin lähettää seuraavan chunkin, joka sisältää oikean sisällön sekä pienen inline-skriptin, joka vaihtaa fallbackin oikeaan sisältöön DOM:issa. Tämä perustuu Reactin Suspense-primitiiviin ja React 19:n Concurrent-renderöintiin.

Käytännön hyöty on kahtalainen. Ensinnäkin TTFB (Time to First Byte) romahtaa, koska palvelin voi vastata heti kun staattinen kuori on valmis (usein muutamassa millisekunnissa). Toiseksi, koska selain saa mitattavaa sisältöä aikaisin, FCP (First Contentful Paint) ja LCP (Largest Contentful Paint) paranevat, mikä nostaa Core Web Vitals -pisteitä ja epäsuorasti hakukonesijoituksia. Käyttäjän kokemus tuntuu myös psykologisesti nopeammalta vaikka absoluuttinen kokonaislatausaika olisi sama; tämä on hyvin dokumentoitu ilmiö perception-of-performance-tutkimuksessa.

loading.tsx vs. Suspense: milloin kumpaa?

Tämä on kysymys, jonka kuulen jokaisessa koodikatselmuksessa. Molemmat käyttävät Reactin Suspense-primitiiviä konepellin alla, mutta niiden käyttötarkoitus on eri.

Ominaisuusloading.tsx<Suspense>
RakeisuusKoko reittisegmenttiYksittäinen komponentti
Milloin näytetäänReittinavigoinnin aikanaEnsimmäisen renderin aikana
SijoitusTiedostona app/**/loading.tsxInline JSX:ssä
Rinnakkainen striimausEi, yksi fallback koko segmentilleKyllä, jokainen boundary itsenäinen
Yleisin käyttötapaSivun skeleton navigoitaessaDashboard-widgetit, hitaat listat
Wrappaa automaattisestipage.tsx:n ja lapsisegmentitVain kääritty JSX

Reittitason loading.tsx

Kun luot tiedoston app/dashboard/loading.tsx, Next.js kääri automaattisesti page.tsx:n Suspense-boundaryyn ja käyttää loading-komponenttia fallbackina. Käyttäjän navigoidessa /dashboard-osoitteeseen selain näkee tämän skeleton-UI:n välittömästi, ja oikea sisältö striimataan sisään sitä mukaa kun se valmistuu.

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="dashboard-skeleton">
      <div className="skeleton-header" />
      <div className="skeleton-grid">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="skeleton-card" />
        ))}
      </div>
    </div>
  );
}

Komponenttitason Suspense

Kun sivulla on useita itsenäisiä data-lähteitä, ne kannattaa kääriä omiin <Suspense>-rajoihinsa jotta ne voivat striimata rinnakkain. Käyttäjä näkee jokaisen widgetin heti kun sen oma data palautuu, eikä koko sivun tarvitse odottaa hitainta kutsua.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserStats, RecentActivity, Notifications } from './widgets';
import { StatsSkeleton, ActivitySkeleton, NotificationsSkeleton } from './skeletons';

export default function DashboardPage() {
  return (
    <section className="dashboard">
      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
      <Suspense fallback={<NotificationsSkeleton />}>
        <Notifications />
      </Suspense>
    </section>
  );
}

Käytännön esimerkki: dashboard rinnakkaisella striimauksella

Katsotaanpa konkreettinen ennen-jälkeen-esimerkki. Alla on tyypillinen sekventiaalinen antipatterni, jonka näen jatkuvasti tuotantokoodissa (törmäsin siihen viime viikollakin eräässä asiakasrepon PR-katselmuksessa):

// ❌ Ennen: sekventiaalinen data-vesiputous
export default async function DashboardPage() {
  const user = await getUser();          // 120 ms
  const stats = await getStats(user.id);  // 340 ms
  const activity = await getActivity(user.id); // 280 ms
  const notifications = await getNotifications(user.id); // 190 ms
  // Yhteensä: 930 ms ennen ensimmäistä tavua

  return (
    <section>
      <UserHeader user={user} />
      <StatsPanel data={stats} />
      <ActivityFeed items={activity} />
      <NotificationList items={notifications} />
    </section>
  );
}

Ongelma on kaksinkertainen: kutsut ovat sekventiaalisia (jokainen await odottaa edellistä) ja koko sivu odottaa niitä kaikkia ennen kuin selain saa ensimmäistä tavua. Chrome DevToolsin Network-välilehdellä TTFB olisi noin 930 ms. Refaktoroidaan tämä rinnakkaisella striimauksella.

// ✅ Jälkeen: async Server Components + Suspense
import { Suspense } from 'react';

async function UserHeader() {
  const user = await getUser();
  return <header>Tervetuloa, {user.name}</header>;
}

async function StatsPanel() {
  const stats = await getStats(); // 340 ms
  return <div className="stats">{/* ... */}</div>;
}

async function ActivityFeed() {
  const items = await getActivity(); // 280 ms
  return <ul>{items.map(i => <li key={i.id}>{i.text}</li>)}</ul>;
}

async function NotificationList() {
  const items = await getNotifications(); // 190 ms
  return <ul>{/* ... */}</ul>;
}

export default function DashboardPage() {
  return (
    <section>
      <Suspense fallback={<HeaderSkeleton />}>
        <UserHeader />
      </Suspense>
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <ActivityFeed />
      </Suspense>
      <Suspense fallback={<NotificationsSkeleton />}>
        <NotificationList />
      </Suspense>
    </section>
  );
}

Nyt TTFB on noin 15–40 ms, eli sen aika joka menee kuoren renderöintiin. Jokainen widget striimataan omassa chunkissaan sitä mukaa kun sen oma fetch valmistuu, ja käyttäjä näkee otsikon välittömästi, tilastot 340 ms:n kohdalla ja niin edelleen. Sama kokonaistyömäärä, mutta havaittu latenssi putoaa dramaattisesti. Vastaavia suunnittelumalleja käymme yksityiskohtaisemmin läpi artikkelissa React Server Components ja Server Actions suunnittelumallit.

Skeleton-fallbackien suunnittelu ilman layout shiftiä

Yleisin virhe striimausta käyttöönotettaessa on skeleton, joka on eri korkuinen kuin lopullinen sisältö. Kun oikea komponentti korvaa fallbackin, sivu hyppii, CLS (Cumulative Layout Shift) romahtaa, ja Google alkaa laskea sitä sinua vastaan Core Web Vitalseissa. Bugin itse pain edellisessä projektissa, jossa skeleton oli 40 pikseliä matalampi kuin oikea kortti, ja mobiilikäyttäjät nappasivat väärän linkin kun sivu hyppäsi juuri klikkauksen kohdalla.

Kolme sääntöä hyvälle skeletonille:

  1. Sama korkeus. Mittaa oikean komponentin todellinen korkeus DevToolsista ja aseta skeletonille sama min-height.
  2. Sama layout. Käytä samoja grid- tai flex-arvoja jotta ympäröivät elementit eivät liiku.
  3. Ei liian villi animaatio. Hienovarainen shimmer-efekti kertoo, että jotain latautuu; villi pulssiva animaatio häiritsee.
// app/dashboard/skeletons.tsx
export function StatsSkeleton() {
  return (
    <div className="stats" style={{ minHeight: 180 }}>
      <div className="skeleton-bar skeleton-shimmer" style={{ width: '60%', height: 24 }} />
      <div className="skeleton-bar skeleton-shimmer" style={{ width: '40%', height: 48, marginTop: 12 }} />
      <div className="skeleton-bar skeleton-shimmer" style={{ width: '80%', height: 16, marginTop: 24 }} />
    </div>
  );
}

Miten mittaan TTFB:n ja striimausrajat DevToolsissa?

Elän tässä välilehdellä. Chrome DevToolsin Network-välilehti näyttää tarkalleen missä chunkit saapuvat.

Vaiheet oman appin profilointiin:

  1. Avaa DevTools → Network-välilehti.
  2. Ota Disable cache päälle ja aseta throttlingiksi Fast 4G tai Slow 4G jotta striimauksen vaikutus näkyy.
  3. Lataa sivu uudelleen ja klikkaa dokumenttipyyntöä (esim. /dashboard).
  4. Katso Timing-välilehteä: Waiting for server response on TTFB.
  5. Klikkaa Response-välilehteä nähdäksesi raakabytet. Striimatussa vastauksessa näet <template>-tageja, jotka Reactin runtime vaihtaa fallbackien tilalle.

Vielä tarkempi tapa on chrome://net-export tai komentoriviltä curl:

curl -N -o /dev/null -w "TTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n" \
  https://your-app.com/dashboard

time_starttransfer on TTFB. Striimatussa sovelluksessa se on tyypillisesti alle 50 ms myös hitaiden data-lähteiden kanssa, kun taas time_total vastaa hitainta Suspense-rajan ratkeamista. Ero näiden välillä on juuri se, mitä olet striimauksella voittanut. Yksityiskohtaisempi tuotannon observoituvuus (traces, spanit per Suspense-boundary) käydään läpi artikkelissa Next.js Instrumentation ja OpenTelemetry.

Virheenkäsittely error.tsx:llä striimauksen aikana

Kun komponentti striimataan ja sen data-fetch heittää poikkeuksen, tarvitset error boundaryn. Ilman sitä koko sivu voi kaatua tai, vielä pahempaa, käyttäjä jää tuijottamaan ikuisesti latautuvaa skeletoniä. Next.js:ssä on kaksi tasoa.

Reittitason error.tsx

// app/dashboard/error.tsx
'use client';

import { useEffect } from 'react';

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    console.error('Dashboard-virhe:', error);
  }, [error]);

  return (
    <div role="alert">
      <h2>Jotain meni pieleen</h2>
      <button onClick={reset}>Yritä uudelleen</button>
    </div>
  );
}

Komponenttitason ErrorBoundary

Jos yksittäisen widgetin fetch epäonnistuu mutta muu sivu pitäisi silti näkyä, kääri se omaan error boundaryyn react-error-boundary-kirjastolla ja sisäkkäin Suspensen kanssa:

import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';

<ErrorBoundary fallback={<p>Tilastoja ei voitu ladata.</p>}>
  <Suspense fallback={<StatsSkeleton />}>
    <StatsPanel />
  </Suspense>
</ErrorBoundary>

Miksi loading.tsx ei näy: yleiset ongelmat

Tämä on top-3 tukikysymys, jonka näen Next.js-yhteisössä. Yleisimmät syyt:

  • Kaikki data on jo välimuistissa. Jos page.tsx ei tosiasiassa suspendoi (kaikki fetchit palauttavat cachesta), loading-fallback ei koskaan renderöidy. Testaa uudella incognito-ikkunalla tai kovalla reloadilla.
  • Väärä tiedostosijainti. loading.tsx pitää olla samassa hakemistossa kuin page.tsx. app/loading.tsx vaikuttaa juurireitille, app/dashboard/loading.tsx koskee vain /dashboard-alipuuta.
  • Client-navigaatio ei osu. Jos linkität <a>-tagilla eikä <Link>-komponentilla, selain tekee full page loadin ja loading.tsx näkyy vain siinä pisteessä, jossa uusi reitti alkaa suspendoida, usein liian myöhään käyttäjän nähtäväksi.
  • Fetch on liian nopea. Alle 100 ms:n fetch ei ehdi näyttää fallbackia. Testaa DevToolsin throttlingilla.
  • Käytät client-komponenttia. loading.tsx on Server Component oletuksena; jos se on client-komponentti eikä sisällä mitään suspend-lähdettä, se renderöi silti oikein, mutta jos ympäröivä sivu on täysin client-side, striimaus ei toimi lainkaan.

Striimaus, use cache ja Partial Prerendering yhdessä

Next.js 16.2:ssa use cache-direktiivi vakautui ja unstable_-prefiksi poistui. Kun yhdistät sen striimaukseen ja Partial Prerenderingiin (PPR), saat kaikki kolme suorituskykyparannusta samasta reitistä.

// app/dashboard/page.tsx
import { Suspense } from 'react';

// Staattinen kuori esirenderöidään build-aikana
export const experimental_ppr = true;

async function CachedStats() {
  'use cache';
  const stats = await getStats(); // Välimuistista
  return <StatsPanel data={stats} />;
}

async function DynamicFeed() {
  // Ei use cachea, joten dynaaminen ja striimataan
  const items = await getActivity();
  return <ActivityFeed items={items} />;
}

export default function Dashboard() {
  return (
    <>
      <h2>Dashboard</h2> {/* Staattinen, PPR:n esirenderöimä */}
      <Suspense fallback={<StatsSkeleton />}>
        <CachedStats />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <DynamicFeed />
      </Suspense>
    </>
  );
}

Näin staattinen kuori tulee CDN:stä muutamassa millisekunnissa, cachetetut widgetit striimautuvat lähes yhtä nopeasti, ja aidosti dynaaminen data striimataan omassa tahdissaan. Syvempi katsaus PPR:ään löytyy artikkelista Next.js Partial Prerendering käytännön opas, ja välimuististrategiat käydään läpi Cache Components -oppaassa. Yksityiskohtaiset konfigurointiohjeet loading.js-konventiolle löydät Next.js:n virallisesta dokumentaatiosta, ja tarkempi katsaus striimausarkkitehtuuriin löytyy Streaming-oppaasta.

Usein kysytyt kysymykset

Mikä ero on loading.tsx:llä ja Suspensella Next.js:ssä?

loading.tsx on tiedostokonventio, joka wrappaa koko reittisegmentin yhdellä Suspense-boundaryllä ja näyttää fallbackin reittinavigoinnin aikana. <Suspense> on JSX-primitiivi, joka mahdollistaa komponenttitason rinnakkaisstriimauksen sivun sisällä. Käytä molempia samassa reitissä.

Voiko Suspense pienentää TTFB:tä?

Kyllä. Striimauksessa palvelin lähettää HTML-kuoren heti ja striimaa Suspense-rajat sitä mukaa kun data valmistuu. TTFB määräytyy nopeimman renderöityvän osan mukaan, ei hitaimman data-fetchin. Tyypillinen pudotus on 40–70 % sekventiaalisesta vesiputousmallista.

Miksi loading.tsx ei näy sivustollani?

Yleisin syy on, että page.tsx ei suspendoi, eli kaikki data tulee välimuistista tai renderöityy synkronisesti. Testaa incognito-ikkunassa DevToolsin throttlingilla. Muita syitä: tiedosto väärässä hakemistossa, käytät <a>-tagia <Link>:n sijaan, tai fetch on alle 100 ms.

Voiko striimauksen aikana palauttaa 404-statuksen?

Ei. Kun ensimmäinen chunk on lähtenyt, HTTP-headerit ja statuskoodi on jo lukittu. Kutsu notFound() aina ennen ensimmäistä awaitia joka voi suspendoida. Jos tarvitset dataa 404-tarkistukseen, tee se ylätason komponentissa ennen Suspense-rajaa.

Toimiiko striimaus edge runtimessa ja Vercelillä?

Kyllä. Vercelin edge- ja Node.js-ajoympäristöt tukevat molemmat chunked transfer encodingiä ja React 19:n striimausrenderöijää. Cloudflaren, AWS Lambdan Response Streamingin ja itse hostattujen Node-palvelinten kanssa striimaus toimii myös, mutta varmista että käytössä ei ole reverse proxya joka bufferoi vastauksia (esim. Nginxin proxy_buffering pitää olla off).

Oliver Schmidt
Tietoa Kirjoittajasta Oliver Schmidt

React performance engineer. Lives in DevTools. Will explain to anyone listening why Suspense changes everything.