Quick Answer: Next.js 15 Suspense streaming cut our Hostinger VPS TTFB from 1.4s to 400ms by sending the shell first and streaming the slow product and pricing blocks afterward. The fix is a server component boundary, a Suspense wrapper with a real fallback, and a loading.tsx that matches the final layout so the page does not shift.
Key Takeaways
- TTFB measures the shell: If your layout awaits a database query, nothing streams. Move slow data into child server components and let the shell render immediately.
- Fallbacks are layout contracts: A skeleton that is 40px shorter than the real card causes layout shift and hurts Core Web Vitals. Match the final dimensions.
- Do not stream auth checks: Session and permission checks belong in the shell. Streaming a private dashboard before the auth check leaks a flash of protected UI.
- Measure at the edge: A 400ms local TTFB can be 900ms from Mumbai on a shared VPS. Test from the region your users actually use.
Why TTFB Was Stuck at 1.4 Seconds
The page was a product catalogue with three slow pieces: a Postgres query for 48 products, a pricing lookup against a remote API, and a review summary that called a third-party service. All three ran in the root page component. The server waited for the slowest one before sending the first byte.
The layout was fast. The shell was fast. The data was the problem, and the architecture forced the shell to wait for the data. That is the most common streaming mistake I see in Next.js 15 codebases.
The Before and After
| Metric | Before | After |
|---|---|---|
| TTFB (Mumbai, P50) | 1.4 s | 400 ms |
| TTFB (Mumbai, P95) | 2.6 s | 780 ms |
| Largest Contentful Paint | 3.1 s | 1.8 s |
| Layout shift | 0.18 | 0.02 |
| Server memory peak | 310 MB | 285 MB |
The product query did not get faster. The review API did not get faster. The page simply stopped waiting for them before showing the header, navigation, and title.
The Boundary That Makes Streaming Work
flowchart TD
A[Request] --> B[Root layout: static shell]
B --> C[Send first byte]
C --> D[Stream product grid]
C --> E[Stream pricing panel]
C --> F[Stream reviews]
D --> G[Suspense boundary resolves]
E --> G
F --> G
G --> H[Hydrate interactive parts]
The rule is simple: the root component should not await slow data. It should render the shell and pass promises down to child server components wrapped in Suspense. Each boundary resolves independently, and the browser paints as soon as the shell arrives.
The Exact Page Structure
Here is the structure I use for a catalogue page. The slow pieces are isolated, and the fallbacks match the final card height.
// app/products/page.tsx
import { Suspense } from "react";
import { ProductGrid } from "./product-grid";
import { PricingPanel } from "./pricing-panel";
import { ReviewSummary } from "./review-summary";
import { GridSkeleton, PanelSkeleton, ReviewSkeleton } from "./skeletons";
export const dynamic = "force-dynamic";
export default function ProductsPage() {
return (
<main className="mx-auto max-w-6xl px-4 py-10">
<header className="mb-8">
<h1 className="text-3xl font-semibold">Products</h1>
<p className="mt-2 text-neutral-600">
Live stock and pricing from our Gujarat warehouse.
</p>
</header>
<section className="grid gap-6 lg:grid-cols-[1fr_320px]">
<Suspense fallback={<GridSkeleton rows={6} />}>
<ProductGrid />
</Suspense>
<Suspense fallback={<PanelSkeleton />}>
<PricingPanel />
</Suspense>
</section>
<Suspense fallback={<ReviewSkeleton />}>
<ReviewSummary />
</Suspense>
</main>
);
}
The child component does the slow work. It is still a server component, so the query stays on the server and the client bundle does not grow.
// app/products/product-grid.tsx
import { db } from "@/lib/db";
import { ProductCard } from "./product-card";
export async function ProductGrid() {
const products = await db.product.findMany({
where: { active: true },
orderBy: { updatedAt: "desc" },
take: 48,
select: {
id: true,
slug: true,
name: true,
priceInr: true,
imageUrl: true,
},
});
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
The loading.tsx file is not enough on its own. It helps route-level navigation, but it does not replace a Suspense boundary inside a page that is already rendering. I use both: loading.tsx for navigation, and Suspense for data islands.
The Fallback Mistake That Costs You CLS
A fallback is not decoration. It is a promise about size. If the skeleton is 320px tall and the real card is 360px, the page shifts when the data arrives, and your layout shift score goes up.
// app/products/skeletons.tsx
export function GridSkeleton({ rows }: { rows: number }) {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: rows }).map((_, index) => (
<div
key={index}
className="h-[360px] animate-pulse rounded-xl border bg-neutral-100"
/>
))}
</div>
);
}
export function PanelSkeleton() {
return <div className="h-[420px] animate-pulse rounded-xl border bg-neutral-100" />;
}
export function ReviewSkeleton() {
return <div className="mt-8 h-[220px] animate-pulse rounded-xl border bg-neutral-100" />;
}
I hard-code the heights from the real components, then test at 360px, 768px, and 1280px widths. If the height changes, the skeleton changes in the same commit.
War Story: The Hostinger VPS That Looked Slow
A Gujarat D2C client moved from a managed platform to a 4 vCPU Hostinger VPS to cut hosting costs. After the move, the catalogue page felt slower even though the server was faster. TTFB from Mumbai was 1.4s P50 and 2.6s P95. The client assumed the VPS was undersized.
I profiled the request. The product query took 180ms. The pricing API took 620ms. The review service took 900ms. The root component awaited all three in sequence, so the first byte waited for the slowest call plus the sum of the others. The CPU was idle for most of that time.
I moved the three data sources into separate server components, added Suspense boundaries, and replaced the review call with a cached summary refreshed every 10 minutes. TTFB dropped to 400ms P50 and 780ms P95. The server did not get bigger. The page stopped waiting.
The client's bounce rate on mobile fell from 61% to 43% over the next three weeks. That is the number that matters, not the benchmark.
When NOT to Use Streaming
Do not stream a page that needs a consistent snapshot across sections. If the price, stock, and cart total must agree, render them together after a single query. Streaming can show a price from one request and stock from another, which creates a support ticket.
Do not stream authenticated dashboards before the session check. Do the auth check in the shell, then stream the data. A flash of another user's data is a security incident, not a performance trade-off.
Do not stream a page that is already fast. If your P95 TTFB is under 200ms, the complexity of multiple boundaries is not worth it. Measure first.
FAQ
Frequently Asked Questions
Does Suspense in Next.js 15 improve TTFB?
Yes, when the slow work is moved into child server components. The shell renders and sends the first byte immediately, then the streamed sections arrive later. In my Hostinger VPS test, TTFB fell from 1.4s to 400ms P50 and from 2.6s to 780ms P95.
What is the difference between loading.tsx and Suspense?
loading.tsx provides a route-level fallback during navigation. Suspense wraps a specific component and streams its fallback while that component's data resolves. Use loading.tsx for route transitions and Suspense for independent data islands inside a page.
Why does my Suspense fallback cause layout shift?
The fallback is usually a different height from the final component. Measure the rendered height at mobile, tablet, and desktop widths, then set the skeleton to the same height. A 40px mismatch is enough to hurt Cumulative Layout Shift.
Can I stream data from a database in Next.js 15?
Yes. Keep the query in an async server component, wrap that component in Suspense, and pass a stable fallback. The query runs on the server, the client never receives the database credentials, and the page streams as each boundary resolves.
Bottom Line
Streaming is not a library feature you enable. It is a boundary decision you make in the component tree. Move the slow work down, make the shell independent, and size the fallback to the real card. If you want the full performance audit that produced these numbers, read how I cut SaaS Next load time from 6.8s to 1.9s, then the AEO checklist and the Next.js 15 MCP tool-calling guide. If your catalogue still waits for the slowest API, send me the URL and I will show you where the shell is blocked.