# Streaming and Suspense in the App Router

Streaming sends the shell while the server works. Where the Suspense boundary goes, what it costs and when it is useless: structure, code and limits.

> Page: https://ui.eneawork.it/blog/nextjs-streaming-suspense
> Markdown: https://ui.eneawork.it/blog/nextjs-streaming-suspense.md

*2026-11-01*

Lo streaming manda l'HTML al browser mentre il server sta ancora lavorando: la struttura compare subito e i dati lenti arrivano dopo. Nell'App Router basta un confine `` attorno al pezzo lento.

## Cosa succede davvero

React renderizza il tree sul server e manda il primo chunk con la shell: header, titolo, skeleton. Quando una promise si risolve, il frammento arriva come `` inline che sostituisce il fallback. Il browser non aspetta la query più lenta per dipingere la pagina.

// app/dashboard/page.tsx import { Suspense } from "react"; import { RevenueChart } from "./revenue-chart"; import { LatestOrders } from "./latest-orders"; import { ChartSkeleton, RowsSkeleton } from "@/components/skeletons"; export default function DashboardPage() { return ( Dashboard }> }> ); }

Un componente server asincrono può essere sospeso senza wrapper se il confine è già nel parent. Il fallback non è decorazione: è il contenuto che l'utente guarda per il tempo dell'attesa.

// app/dashboard/revenue-chart.tsx export async function RevenueChart() { const res = await fetch("https://api.example.com/revenue", { next: { revalidate: 60 }, }); if (!res.ok) throw new Error(`Revenue API ${res.status}`); const data = await res.json(); return ; }

## Dove mettere il confine

`loading.tsx` è zucchero: avvolge l'intero segmento in un confine. Va bene per una pagina che carica tutta insieme, è sbagliato per una dashboard dove tre pannelli hanno tempi diversi.

// app/dashboard/latest-orders.tsx import { unstable_cache } from "next/cache"; import { db } from "@/lib/db"; const latestOrders = unstable_cache( async () => db.order.findMany({ take: 10, orderBy: { createdAt: "desc" } }), ["latest-orders"], { revalidate: 30 }, ); export async function LatestOrders() { const rows = await latestOrders(); return ; }

## Il costo: TTFT basso, CLS da controllare

Lo streaming abbassa il Time to First Byte percepito e non tocca il tempo totale del server. Il rischio si sposta sul layout: se lo skeleton non ha l'altezza del contenuto finale, ogni arrivo produce uno shift.

## Il record che uso come esempio

Il catalogo EneUI indicizza 111.985 componenti, 1.556 blocchi e 12.808 icone da 1.472 librerie, misurati il 1 novembre 2026 su `/api/stats`. Per questo articolo il record utile è streaming text; gli skeleton stanno in skeleton e gli stati di attesa in loading.

## Quando lo streaming peggiora le cose

Metti il confine attorno al dato lento, non attorno alla pagina. Se non sai quale sia il dato lento, misura prima di aggiungere uno skeleton.

Per la differenza tra mutazioni e letture vedi Route Handlers vs Server Actions. Per gli errori che arrivano dopo lo stream c'è error boundary nell'App Router. Per la cache che sopravvive alla richiesta leggi stale-while-revalidate.

Limite noto: se un componente sopra il confine legge `searchParams`, l'intera pagina diventa dinamica e il primo byte aspetta quella lettura. Lo streaming non aggira il rendering dinamico, lo espone.

## FAQ

### Serve un `loading.tsx` per fare streaming?

No. Basta un `` nel punto giusto. `loading.tsx` avvolge l'intero segmento e va bene solo quando la pagina carica tutta insieme.

### Lo streaming rallenta il server?

No, il lavoro totale è lo stesso. Cambia la distribuzione: il primo byte parte prima e i pezzi arrivano man mano.

### Posso mettere un confine dentro un client component?

Sì, ma il figlio sospeso deve essere un server component passato come children. Un client component non può renderizzare un async component.

### Come evito il layout shift dallo skeleton?

Dai al fallback la stessa altezza del contenuto finale, con `min-height` o una griglia di righe fisse. Il resto lo misura il browser.

### Lo streaming funziona con l'export statico?

No. Con `output: "export"` la pagina è un file completo e non c'è server che possa inviare pezzi dopo il primo byte.
