Performance Benchmarks in Next.js 16 - SSR vs SSG vs ISR
Next.js course · Module 5: Rendering Strategies
In the Quantum Metropolis of 2150, system performance is a matter of survival. Every millisecond of delay can mean data loss in the quantum network or failure of critical systems. That's why Metropolis engineers must thoroughly understand the performance differences between rendering strategies in Next.js 16 and be able to choose the optimal solution for each case.
What Are Performance Benchmarks?
Benchmarks are performance measurements of an application under controlled conditions. They allow comparing different rendering strategies against key metrics such as:
- TTFB (Time to First Byte) - time from sending a request to receiving the first byte of the response
- FCP (First Contentful Paint) - time until the first content is rendered on screen
- LCP (Largest Contentful Paint) - time until the largest element in the viewport is rendered
- TTI (Time to Interactive) - time until the page is fully interactive
- CLS (Cumulative Layout Shift) - visual stability of the page during loading
Comparing Rendering Strategies
SSG (Static Site Generation) - The Fastest Option
SSG generates pages at application build time. The resulting HTML files are served directly from a CDN, ensuring the lowest possible latency.
1// app/blog/[slug]/page.tsx - SSG in Next.js 16
2import { notFound } from 'next/navigation';
3
4// Generating static paths at build time
5export async function generateStaticParams() {
6 const posts = await fetch('https://api.example.com/posts').then(r => r.json());
7 return posts.map((post: { slug: string }) => ({ slug: post.slug }));
8}
9
10// Without request-time APIs the page is prerendered at build time (SSG).
11// Since Next.js 15 fetch is not cached by default, so we enable the cache explicitly
12async function getPost(slug: string) {
13 const res = await fetch(`https://api.example.com/posts/${slug}`, { cache: 'force-cache' });
14 if (!res.ok) return null;
15 return res.json();
16}
17
18export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
19 const post = await getPost((await params).slug);
20 if (!post) notFound();
21
22 return (
23 <article>
24 <h1>{post.title}</h1>
25 <p>{post.content}</p>
26 </article>
27 );
28}Typical SSG Metrics:
| Metric | Value | Rating | |--------|-------|--------| | TTFB | 5-50ms | Excellent | | FCP | 100-300ms | Excellent | | LCP | 200-500ms | Excellent | | TTI | 300-800ms | Very good | | CLS | 0-0.05 | Excellent |
SSR (Server-Side Rendering) - Dynamic Content
SSR renders pages on the server for every request. It is slower than SSG but ensures always up-to-date data.
1// app/dashboard/page.tsx - SSR in Next.js 16
2import { cookies } from 'next/headers';
3
4// Forcing dynamic rendering
5export const dynamic = 'force-dynamic';
6
7async function getDashboardData(token: string) {
8 const res = await fetch('https://api.example.com/dashboard', {
9 headers: { Authorization: `Bearer ${token}` },
10 cache: 'no-store', // No caching = SSR
11 });
12 return res.json();
13}
14
15export default async function Dashboard() {
16 const cookieStore = await cookies();
17 const token = cookieStore.get('auth-token')?.value || '';
18 const data = await getDashboardData(token);
19
20 return (
21 <div>
22 <h1>User Dashboard</h1>
23 <p>Balance: {data.balance} QC</p>
24 <p>Last activity: {data.lastActivity}</p>
25 </div>
26 );
27}Typical SSR Metrics:
| Metric | Value | Rating | |--------|-------|--------| | TTFB | 100-500ms | Average | | FCP | 300-800ms | Good | | LCP | 500-1200ms | Average | | TTI | 500-1500ms | Average | | CLS | 0-0.1 | Good |
ISR (Incremental Static Regeneration) - The Compromise
ISR combines the advantages of SSG (speed) with SSR (data freshness). Pages are generated statically but refreshed in the background after a specified time period.
1// app/products/page.tsx - ISR in Next.js 16
2async function getProducts() {
3 const res = await fetch('https://api.example.com/products', {
4 next: { revalidate: 60 }, // Revalidation every 60 seconds
5 });
6 return res.json();
7}
8
9export default async function ProductsPage() {
10 const products = await getProducts();
11
12 return (
13 <div>
14 <h1>Quantum Store Products</h1>
15 <div className="grid">
16 {products.map((product: any) => (
17 <div key={product.id}>
18 <h2>{product.name}</h2>
19 <p>Price: {product.price} QC</p>
20 </div>
21 ))}
22 </div>
23 </div>
24 );
25}Typical ISR Metrics:
| Metric | Value (cache hit) | Value (revalidation) | Rating | |--------|-------------------|----------------------|--------| | TTFB | 5-50ms | 100-400ms | Very good | | FCP | 100-300ms | 300-700ms | Very good | | LCP | 200-500ms | 500-1000ms | Good | | TTI | 300-800ms | 500-1200ms | Good | | CLS | 0-0.05 | 0-0.05 | Excellent |
On-Demand Revalidation in Next.js 16
On-demand revalidation lets you refresh the cache without waiting for the revalidation time to expire. Next.js 16 changed its API: revalidateTag now takes a second argument, a cacheLife profile (the recommended 'max' means the user gets the old version while the fresh one loads in the background), and the single-argument form is deprecated. In Server Actions, the new updateTag function refreshes data immediately:
1// app/api/revalidate/route.ts
2import { revalidatePath, revalidateTag } from 'next/cache';
3import { NextRequest, NextResponse } from 'next/server';
4
5export async function POST(request: NextRequest) {
6 const { path, tag, secret } = await request.json();
7
8 // Security token verification
9 if (secret !== process.env.REVALIDATION_SECRET) {
10 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
11 }
12
13 if (tag) {
14 // Tag-based revalidation - refreshes all fetches with the given tag
15 revalidateTag(tag, 'max');
16 return NextResponse.json({ revalidated: true, tag });
17 }
18
19 if (path) {
20 // Path-based revalidation - refreshes a specific page
21 revalidatePath(path);
22 return NextResponse.json({ revalidated: true, path });
23 }
24
25 return NextResponse.json({ error: 'Missing path or tag' }, { status: 400 });
26}
27
28// Using tags in fetch
29async function getProduct(id: string) {
30 const res = await fetch(`https://api.example.com/products/${id}`, {
31 next: {
32 revalidate: 3600, // Cache for 1 hour
33 tags: [`product-${id}`, 'products'], // Tags for revalidation
34 },
35 });
36 return res.json();
37}Hybrid Rendering - Mixing Strategies
In Next.js 16, you can combine different rendering strategies within a single page thanks to Partial Prerendering (PPR). It requires cacheComponents: true: the part with the 'use cache' directive then goes into the static shell, and the dynamic part wrapped in <Suspense> streams in at request time. Without this option the same code still streams data, but the whole route is rendered on every request:
1// app/store/page.tsx - Hybrid approach
2import { Suspense } from 'react';
3import { cacheLife } from 'next/cache';
4
5// Static part of the page (SSG)
6function StaticHeader() {
7 return (
8 <header>
9 <h1>Quantum Store</h1>
10 <nav>
11 <a href="/products">Products</a>
12 <a href="/categories">Categories</a>
13 </nav>
14 </header>
15 );
16}
17
18// Dynamic part of the page (SSR)
19async function DynamicCart() {
20 const res = await fetch('https://api.example.com/cart', { cache: 'no-store' });
21 const cart = await res.json();
22
23 return (
24 <div>
25 <h2>Your Cart ({cart.items.length} products)</h2>
26 <p>Total: {cart.total} QC</p>
27 </div>
28 );
29}
30
31// Cached part (formerly ISR) - goes into the static shell
32async function ProductList() {
33 'use cache';
34 cacheLife({ revalidate: 300 });
35
36 const res = await fetch('https://api.example.com/featured');
37 const products = await res.json();
38
39 return (
40 <div>
41 {products.map((p: any) => (
42 <div key={p.id}>{p.name} - {p.price} QC</div>
43 ))}
44 </div>
45 );
46}
47
48export default function StorePage() {
49 return (
50 <div>
51 <StaticHeader />
52 <Suspense fallback={<p>Loading cart...</p>}>
53 <DynamicCart />
54 </Suspense>
55 <Suspense fallback={<p>Loading products...</p>}>
56 <ProductList />
57 </Suspense>
58 </div>
59 );
60}How to Measure Performance in Next.js 16?
Web Vitals Reporting
1// app/layout.tsx
2import { SpeedInsights } from '@vercel/speed-insights/next';
3import { Analytics } from '@vercel/analytics/react';
4
5export default function RootLayout({ children }: { children: React.ReactNode }) {
6 return (
7 <html lang="en">
8 <body>
9 {children}
10 <SpeedInsights />
11 <Analytics />
12 </body>
13 </html>
14 );
15}Built-in Next.js Profiler
1# Building with profiling
2NEXT_TELEMETRY_DEBUG=1 next build
3
4# Bundle size analysis
5ANALYZE=true next buildWhen to Use Which Strategy?
| Scenario | Strategy | Why? | |----------|----------|------| | Blog, documentation | SSG | Content doesn't change often | | E-commerce (product list) | ISR (60-300s) | Products change, but not every second | | User dashboard | SSR | Data must always be current | | Home page with promotions | ISR (300-3600s) | Promotions change every few hours | | Admin panel | SSR | Requires current data and authorization | | Landing page | SSG | Maximum speed, static content | | Social feed | SSR + Streaming | Dynamic data, progressive loading |
Summary
The choice of rendering strategy in Next.js 16 should be driven by the specific requirements of the project:
- SSG - the fastest option, ideal for static content
- ISR - a compromise between speed and data freshness
- SSR - full dynamism at the cost of performance
- Hybrid (PPR) - the future of rendering, combining all strategies
In the Quantum Metropolis of 2150, engineers base their decisions on data - they measure, analyze, and optimize. Performance benchmarks are a key tool in this process.
Code for this lesson: App.tsx
1import React, { useState } from 'react';
2
3// Performance Benchmarks - SSR vs SSG vs ISR
4
5interface BenchmarkResult {
6 strategy: string;
7 ttfb: number;
8 fcp: number;
9 lcp: number;
10 tti: number;
11 cls: number;
12 description: string;
13 useCase: string;
14}
15
16const benchmarks: BenchmarkResult[] = [
17 {
18 strategy: 'SSG (Static Site Generation)',
19 ttfb: 25,
20 fcp: 180,
21 lcp: 350,
22 tti: 500,
23 cls: 0.02,
24 description: 'Pages generated at build time. Served from CDN.',
25 useCase: 'Blog, documentation, landing page',
26 },
27 {
28 strategy: 'ISR (Incremental Static Regeneration)',
29 ttfb: 30,
30 fcp: 200,
31 lcp: 400,
32 tti: 550,
33 cls: 0.03,
34 description: 'Static pages refreshed in the background after a set time.',
35 useCase: 'E-commerce, product catalogs',
36 },
37 {
38 strategy: 'SSR (Server-Side Rendering)',
39 ttfb: 280,
40 fcp: 550,
41 lcp: 900,
42 tti: 1100,
43 cls: 0.08,
44 description: 'Pages rendered on the server on every request.',
45 useCase: 'Dashboard, user panel',
46 },
47];
48
49function MetricBar({ value, max, label, unit }: { value: number; max: number; label: string; unit: string }) {
50 const pct = (value / max) * 100;
51 const color = pct < 33 ? '#4caf50' : pct < 66 ? '#ff9800' : '#f44336';
52 return (
53 <div style={{ marginBottom: 8 }}>
54 <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: '#aaa', marginBottom: 2 }}>
55 <span>{label}</span>
56 <span style={{ color }}>{value}{unit}</span>
57 </div>
58 <div style={{ height: 8, background: '#1a2744', borderRadius: 4, overflow: 'hidden' }}>
59 <div style={{ width: pct + '%', height: '100%', background: color, borderRadius: 4, transition: 'width 0.6s ease' }} />
60 </div>
61 </div>
62 );
63}
64
65export default function BenchmarkDemo() {
66 const [selected, setSelected] = useState(0);
67 const b = benchmarks[selected];
68
69 return (
70 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#fff', fontFamily: 'monospace' }}>
71 <h1 style={{ color: '#64ffda', fontSize: 20, marginBottom: 4 }}>Performance Benchmarks</h1>
72 <p style={{ color: '#888', fontSize: 13, marginBottom: 16 }}>SSR vs SSG vs ISR - performance comparison</p>
73
74 <div style={{ display: 'flex', gap: 8, marginBottom: 20, flexWrap: 'wrap' }}>
75 {benchmarks.map((bm, i) => (
76 <button key={i} onClick={() => setSelected(i)} style={{
77 padding: '8px 14px', border: '1px solid ' + (i === selected ? '#64ffda' : '#334'),
78 background: i === selected ? '#1a3a4a' : '#151530', color: i === selected ? '#64ffda' : '#aaa',
79 borderRadius: 6, cursor: 'pointer', fontSize: 12,
80 }}>
81 {bm.strategy.split(' ')[0]}
82 </button>
83 ))}
84 </div>
85
86 <div style={{ background: '#151530', borderRadius: 8, padding: 16, marginBottom: 16 }}>
87 <h2 style={{ color: '#64ffda', fontSize: 16, marginBottom: 4 }}>{b.strategy}</h2>
88 <p style={{ color: '#888', fontSize: 12, marginBottom: 12 }}>{b.description}</p>
89
90 <MetricBar value={b.ttfb} max={500} label="TTFB (Time to First Byte)" unit="ms" />
91 <MetricBar value={b.fcp} max={1200} label="FCP (First Contentful Paint)" unit="ms" />
92 <MetricBar value={b.lcp} max={1500} label="LCP (Largest Contentful Paint)" unit="ms" />
93 <MetricBar value={b.tti} max={1500} label="TTI (Time to Interactive)" unit="ms" />
94 <MetricBar value={b.cls} max={0.15} label="CLS (Cumulative Layout Shift)" unit="" />
95 </div>
96
97 <div style={{ background: '#151530', borderRadius: 8, padding: 12 }}>
98 <p style={{ color: '#64ffda', fontSize: 12, marginBottom: 4 }}>Best use case:</p>
99 <p style={{ color: '#ccc', fontSize: 13 }}>{b.useCase}</p>
100 </div>
101 </div>
102 );
103}