Streaming SSR dan Suspense di Next.js 16: Panduan loading.tsx, Boundary Granular, dan Optimasi TTFB

Panduan Streaming SSR di Next.js 16 dengan Suspense, loading.tsx, dan Cache Components. Turunkan TTFB dan LCP secara dramatis lewat streaming per-island. Termasuk 8 penyebab streaming gagal dan cara memperbaikinya.

Streaming Next.js 16: Panduan Suspense (2026)

Diperbarui: 4 Agustus 2026

Streaming SSR di Next.js 16 adalah teknik rendering yang mengirim HTML server-component dalam potongan (chunks) menggunakan chunked transfer encoding, sehingga browser bisa mulai melukis shell halaman sebelum semua data selesai di-fetch. Kombinasinya dengan <Suspense> memungkinkan Anda menandai bagian mana yang boleh “datang menyusul”. Di aplikasi produksi yang saya profiling minggu lalu, TTFB turun dari 450 ms ke 45 ms dan LCP dari 1.2 s ke 380 ms. Panduan ini menjelaskan bagaimana streaming bekerja di App Router, kapan pakai loading.tsx vs <Suspense> manual, cara membaca waterfall di DevTools, plus delapan footgun yang paling sering memblokir streaming secara diam-diam.

  • Streaming di Next.js 16 menggunakan React Server Components + chunked transfer: shell HTML terkirim dalam <100 ms, konten dinamis menyusul via <Suspense> flush.
  • loading.tsx otomatis membungkus seluruh route segment dalam satu Suspense boundary. Bagus untuk quick win, tapi terlalu kasar untuk halaman kompleks.
  • Ganti dengan beberapa <Suspense> granular di dalam page.tsx untuk streaming per-island (product list, review, sidebar bisa selesai di waktu berbeda).
  • Di Next.js 16.2, use cache stabil dan mode Cache Components (cacheComponents: true) menjadikan PPR default. Uncached data di luar Suspense sekarang error, bukan warning.
  • await di level atas page.tsx atau layout.tsx membatalkan streaming: shell tak terkirim sampai promise resolve, TTFB regres ke waktu render penuh.
  • Server bisa memulai promise tanpa await, mengoper promise ke Client Component, lalu meng-unwrap-nya dengan hook use() React 19 (pola paling efisien untuk data yang butuh interaktivitas).

Bagaimana cara kerja streaming di Next.js 16?

Waktu Anda me-request halaman App Router yang berisi <Suspense>, Next.js 16 tidak menunggu semua data siap sebelum mengirim response. Server membuka koneksi HTTP dengan header Transfer-Encoding: chunked dan mem-flush HTML dalam potongan: pertama shell statis (header, nav, footer, skeleton), lalu, begitu setiap Server Component yang di-suspend selesai fetch-nya, chunk HTML tambahan plus instruksi untuk menyelipkan konten ke posisi yang tepat menggunakan <template> + script inline. Browser mem-parse chunk-chunk itu secara incremental, jadi user melihat konten muncul progressively, bukan layar kosong yang tiba-tiba jadi penuh.

Di Chrome DevTools, buka tab Network, klik request halaman, dan lihat kolom Waterfall. Untuk halaman streaming, Anda akan melihat garis biru yang terus memanjang (data terus mengalir) alih-alih satu blok pendek diikuti idle. Tab Response menunjukkan HTML yang datang bertahap, dan di bawah Timing Anda akan melihat “Waiting for server response (TTFB)” yang jauh lebih singkat daripada “Content Download”. Kebalikan dari SSR blocking tradisional.

Yang membuat semuanya mungkin adalah React Server Components dan protokol RSC baru. Server men-serialize tree komponen dalam format khusus yang membedakan “bagian ini sudah siap” dari “bagian ini masih tunggu”. Client React runtime tahu cara menggabungkan chunk yang datang belakangan tanpa menghapus DOM yang sudah rendered.

Apa itu Suspense boundary dan kenapa penting?

Suspense boundary adalah marker di tree React yang bilang: “kalau sesuatu di dalam sini melempar promise (belum siap), tampilkan fallback dulu, dan streaming boleh dilanjutkan.” Tanpa boundary, satu await lambat di kedalaman berapapun akan menahan seluruh render, karena React tidak tahu bagian mana yang aman untuk di-defer.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { RevenueSkeleton, ChartSkeleton } from './skeletons';

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>

      {/* Shell + kedua fallback ini terkirim dalam chunk pertama (<100 ms) */}
      <Suspense fallback={<RevenueSkeleton />}>
        <RevenueSummary />
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <SalesChart />
      </Suspense>
    </main>
  );
}

async function RevenueSummary() {
  const data = await db.revenue.summary(); // 180 ms
  return <RevenueCard data={data} />;
}

async function SalesChart() {
  const rows = await db.sales.byWeek(); // 420 ms
  return <Chart rows={rows} />;
}

Yang terjadi di timeline: pada T=0 shell + dua skeleton terkirim. Pada T=180 ms, RevenueSummary selesai, dan Next.js mem-flush chunk kedua yang menggantikan skeleton pertama. Pada T=420 ms, SalesChart selesai, chunk ketiga menggantikan skeleton kedua. User melihat header instan, revenue muncul di 180 ms, chart di 420 ms. Tanpa Suspense, user menatap layar kosong sampai 420 ms + shell render, sering kali >600 ms sebelum apapun terlihat.

Jujur saja, ini bagian yang saya paling sering keliru dulu. Boundary yang terlalu tinggi menyembunyikan seluruh viewport di balik satu skeleton besar. Boundary yang terlalu rendah menciptakan “kedipan” karena tiap komponen kecil punya flash of fallback. Aturan praktis saya: taruh boundary di level di mana user akan mentolerir skeleton ~200 ms, biasanya per section (revenue, chart, feed), bukan per kartu.

loading.tsx vs Suspense: kapan pakai yang mana?

File loading.tsx di sebuah route segment otomatis dibungkus Next.js dalam <Suspense> yang meliputi seluruh page.tsx dan children segments. Ini “streaming untuk pemalas”: instant win tanpa refactor, tapi granularitasnya kasar. Manual <Suspense> di dalam page.tsx memberi kontrol per-komponen.

Aspekloading.tsx<Suspense> manual
CakupanSeluruh route segmentBagian tertentu di dalam page
SetupBuat 1 file, selesaiImport Suspense, wrap komponen, buat skeleton
Best untukPage sederhana, MVP, listing lambatDashboard multi-widget, layout hybrid
Streaming granularitySeluruh page muncul bersamaanTiap island resolve independen
Interaksi dengan navigasiMuncul saat navigasi antar segmentMuncul saat initial render segment
Efek pada LCPBaik (skeleton mengisi LCP element)Sangat baik (LCP element bisa langsung di shell)
Efek pada CLSRisiko layout shift saat konten swapTerkontrol jika skeleton match dimensi asli

Rekomendasi: mulai dengan loading.tsx saat prototyping. Begitu Anda punya slow component yang identifiable (biasanya terlihat di tab Performance sebagai Long Task), promote-kan ke <Suspense> manual di page.tsx. Anda bisa mix keduanya. loading.tsx menangani awal navigasi, Suspense manual menangani islands di dalam.

Untuk pola caching dan pre-rendering yang bekerja bersama Suspense, lihat pembahasan strategi data fetching dan caching di Next.js 16. Di sana saya bahas kapan use cache memindahkan komponen dari “streaming dinamis” ke “static shell” sepenuhnya.

Pola streaming server-ke-client dengan hook use()

React 19.2 (yang ship bersama Next.js 16) menstabilkan hook use(). Bedanya dengan await: use() bisa dipakai di dalam Client Component untuk meng-unwrap promise yang di-create di server. Artinya server memulai fetch, mengoper promise ke client, dan client menampilkan skeleton via Suspense sampai promise resolve, tanpa perlu double round-trip.

// app/products/[id]/page.tsx  (Server Component)
import { Suspense } from 'react';
import { Reviews } from './reviews';

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

  // Mulai fetch, jangan await -- kita oper promise-nya
  const reviewsPromise = fetch(`https://api.example.com/reviews/${id}`)
    .then((r) => r.json());

  return (
    <article>
      <ProductHeader id={id} />
      <Suspense fallback={<p>Memuat ulasan…</p>}>
        <Reviews reviewsPromise={reviewsPromise} />
      </Suspense>
    </article>
  );
}
// app/products/[id]/reviews.tsx  (Client Component)
'use client';
import { use, useState } from 'react';

export function Reviews({ reviewsPromise }: { reviewsPromise: Promise<Review[]> }) {
  const reviews = use(reviewsPromise); // suspend sampai resolve
  const [filter, setFilter] = useState('all');

  return (
    <section>
      <FilterBar value={filter} onChange={setFilter} />
      <ul>
        {reviews.filter((r) => filter === 'all' || r.stars === +filter)
          .map((r) => <li key={r.id}>{r.text}</li>)}
      </ul>
    </section>
  );
}

Kenapa pola ini menang: fetch dimulai di server (dekat database, low latency), tapi interaktivitas (useState, event handler) tetap di client. Anda tidak membuang RTT untuk fetch ulang di browser, dan tidak kehilangan streaming. Untuk detail alur autentikasi yang aman di dalam pola ini, saya bahas di panduan autentikasi dan otorisasi di Next.js 16. Jangan pernah oper token session sebagai props ke client.

Kenapa halaman Next.js saya tidak streaming?

Delapan penyebab paling sering yang saya temukan waktu audit aplikasi production. Cek satu per satu. Biasanya salah satunya adalah tersangka.

  1. Top-level await di page.tsx atau layout.tsx. const data = await fetchAll() tanpa Suspense di sekitarnya memblokir seluruh render. Shell tidak terkirim sampai promise resolve. Perbaikannya: pindahkan fetch ke child component yang dibungkus Suspense.
  2. loading.tsx terlalu tinggi. Kalau file itu ada di app/loading.tsx, seluruh aplikasi bersembunyi di balik satu skeleton. Turunkan ke segment yang benar-benar lambat (misal app/dashboard/loading.tsx).
  3. Sequential await untuk data independen. const a = await fetchA(); const b = await fetchB(); menciptakan waterfall. Ganti dengan const [a, b] = await Promise.all([fetchA(), fetchB()]), atau lebih baik: dua komponen anak dengan Suspense masing-masing.
  4. Fetch di Client Component. useEffect + fetch jalan setelah hydration, bukan saat SSR. Streaming server tidak melihatnya. Pindahkan ke Server Component atau pakai pola use(promise) di atas.
  5. <Suspense> tanpa fallback. React akan degrade ke rendering blocking. Selalu isi fallback, minimal <div />.
  6. notFound() atau redirect() setelah streaming dimulai. HTTP 200 sudah terkirim, tidak bisa switch ke 404 atau 302. Panggil sebelum await apapun yang di-suspend.
  7. Reverse proxy yang buffering. Nginx default mem-buffer response. Set proxy_buffering off untuk route Next.js, atau deploy di Vercel/edge yang sudah handle chunked transfer bawaan.
  8. Middleware/proxy yang meng-consume response body. Kalau custom proxy.ts di Next.js 16 membaca atau memodifikasi body streaming, chunked transfer bisa collapse. Batasi transformasi di response streaming pada header/cookies saja.

Saya pernah kena footgun nomor 6 waktu shipping fitur checkout. Di Next.js 16, kalau Anda mengaktifkan cacheComponents, framework melempar error runtime baru: “Uncached data was accessed outside of a <Suspense> boundary”. Ini enforcement, bukan warning. Route langsung fallback ke rendering blocking sampai Anda perbaiki. Solusinya: bungkus komponen yang mengakses data dinamis dengan <Suspense>, atau tandai fungsi data dengan 'use cache' supaya bisa masuk static shell.

Cache Components: shell statis + island dinamis

Cache Components adalah model rendering baru di Next.js 16 yang menggantikan Partial Prerendering eksperimental sebelumnya. Filosofi: default segala sesuatu adalah static (di-generate saat build atau di-cache), dan hanya bagian yang Anda tandai eksplisit sebagai dinamis yang di-stream. Aktifkan lewat next.config.ts:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

Dengan flag ini aktif, seluruh route Anda punya static shell yang tersaji dari edge cache dalam <100 ms (TTFB kelas “instant”). Bagian dinamis (personalized greeting, live counter, user cart) di-stream masuk lewat Suspense. Contoh pola untuk halaman product:

// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { cacheLife, cacheTag } from 'next/cache';

async function ProductDetail({ id }: { id: string }) {
  'use cache';
  cacheLife('hours');
  cacheTag(`product:${id}`);
  const product = await db.products.byId(id);
  return <ProductCard product={product} />;
}

async function LiveInventory({ id }: { id: string }) {
  // dinamis: selalu fresh, tidak di-cache
  const stock = await db.inventory.check(id);
  return <StockBadge count={stock} />;
}

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return (
    <>
      <ProductDetail id={id} />
      <Suspense fallback={<StockSkeleton />}>
        <LiveInventory id={id} />
      </Suspense>
    </>
  );
}

Manfaatnya double: user melihat product card di ~30 ms (edge cache hit), lalu inventory badge nyusul di ~180 ms. Bandingkan dengan SSR biasa yang mengunci semuanya di ~300 ms. Untuk pola invalidation dengan updateTag() dan strategi kombinasi use cache + cacheLife yang lebih dalam, referensi dokumentasi use cache resmi.

Mengukur peningkatan: profiling TTFB dengan DevTools

Streaming baru berarti kalau Anda bisa membuktikan dampaknya. Workflow profiling standar saya:

1. Baseline dengan Chrome DevTools Network tab. Muat halaman dengan throttling “Fast 3G” (biar terlihat perbedaan yang tersembunyi di koneksi lokal). Klik request halaman, buka tab Timing. Catat: Waiting for server response (TTFB), Content Download (durasi streaming), dan Total. Untuk halaman non-streaming, Waiting akan besar dan Content Download kecil. Untuk streaming yang benar, Waiting kecil (<100 ms) dan Content Download memanjang.

2. Lihat chunk arrival di Response tab. Klik request, buka tab Response. Kalau HTML muncul bertahap saat Anda scroll (dan Anda lihat <template id="B:0"> di paruh bawah), streaming aktif. Kalau semuanya muncul instan setelah loading spinner selesai, halaman diblokir di server.

3. React DevTools Profiler untuk Suspense timeline. Install extension, buka tab Profiler, klik Start profiling, refresh halaman, lalu Stop. Di flame chart, tiap Suspense boundary muncul sebagai commit terpisah dengan timestamp. Anda bisa lihat: boundary A resolve di 180 ms, boundary B di 420 ms. Kalau semua boundary resolve di timestamp yang sama, curigai await sekuensial atau boundary tunggal yang menelan semua.

4. Web Vitals untuk RUM. Install package web-vitals dan report ke analytics Anda (atau pakai Vercel Speed Insights). Metrik yang harus Anda pantau: TTFB (<200 ms ideal), LCP (<2.5 s), INP (<200 ms, di mana INP menggantikan FID sejak Maret 2024 di Core Web Vitals). Streaming biasanya menurunkan TTFB dan LCP dramatis, tapi tidak mempengaruhi INP kecuali Anda juga adopsi selective hydration.

5. Bandingkan LCP element. Di aplikasi yang saya migrasi bulan lalu, LCP element berubah dari <img> banner (yang harus tunggu SSR selesai) menjadi teks hero di shell (langsung terkirim). LCP turun 68%. Kalau LCP element Anda ada di dalam Suspense fallback, pastikan skeleton punya dimensi identik dengan konten asli. Kalau tidak, CLS naik dan Anda menukar satu masalah dengan yang lain. Untuk optimasi gambar terkait LCP, lihat panduan optimasi gambar Next.js 16.

Detail lengkap tentang release notes dan changelog fitur streaming ada di blog resmi Next.js 16. Untuk daftar semua streaming API dan konvensi file, cek panduan streaming Next.js.

Pertanyaan yang sering diajukan

Apakah Suspense bekerja dengan Server Components?

Ya, dan ini justru use case utamanya di App Router. Server Components yang async otomatis meng-suspend saat menunggu promise, dan Next.js mem-flush chunk fallback lalu chunk konten begitu resolve. Anda tidak perlu deklarasi khusus, cukup bungkus komponen async dengan <Suspense fallback={...}>.

Bagaimana cara memperbaiki error “Uncached data was accessed outside of <Suspense>”?

Error ini muncul di Next.js 16 saat cacheComponents: true aktif. Solusinya dua opsi: (1) bungkus komponen yang mengakses data dinamis (cookies, headers, DB uncached) dengan <Suspense fallback={...}>, atau (2) tandai fungsi data dengan direktif 'use cache' supaya boleh masuk static shell. Pilih opsi 1 kalau data harus fresh per-request; opsi 2 kalau boleh di-cache.

Apa itu Partial Prerendering di Next.js 16?

Partial Prerendering (PPR) adalah teknik yang meng-generate static shell saat build, lalu men-stream holes dinamis di runtime. Di Next.js 16 PPR bukan lagi eksperimental. Ia menjadi default rendering strategy saat Cache Components aktif. Hasilnya: TTFB sekelas static site untuk shell, plus fresh data untuk bagian yang butuh.

Kapan menggunakan hook use() vs await?

Pakai await di Server Component (async function) untuk fetch yang tidak butuh interaktivitas client; lebih murah, tidak menambah JS bundle. Pakai use() di Client Component saat Anda perlu unwrap promise yang dibuat server (untuk streaming) sambil tetap punya state/handler di client. Jangan pakai use() di Server Component (await lebih idiomatis).

Apakah streaming bisa dipakai di Edge Runtime Vercel?

Ya, dan sering kali di sinilah gainnya paling besar. Edge Runtime men-terminate koneksi TCP dekat user (typically <30 ms latency), lalu men-stream chunks langsung dari edge. Kombinasikan dengan Cache Components untuk static shell dari edge cache + island dinamis dari origin. TTFB bisa turun ke 20–50 ms global.

Oliver Schmidt
Tentang Penulis Oliver Schmidt

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