React 19.2's Partial Pre-Rendering: Making Your Apps Load 10x Faster
Welcome Back!
Quick series recap for those following along:
Apologies for the small break in continuity—life as a Senior Software Engineer keeps me busy with exciting projects! But I'm back with another deep-dive, and I promise to deliver the full series. Today's topic is one of React 19.2's most revolutionary features.
Let's dive into Partial Pre-Rendering (PPR)—the technique that's transforming how modern web apps load.
The Problem: Users Wait While Servers Work
Imagine this scenario: You're building an e-commerce product page. Your user clicks "View Product." They want to see:
✅ Product title
✅ Images
✅ Description
✅ Price
But your backend also needs to:
🔄 Fetch 300 user reviews
🔄 Generate personalised recommendations
🔄 Check real-time inventory
🔄 Load promotional banners
The old reality: Everything blocks. Users see a blank screen for 3-5 seconds while the server waits for ALL data to arrive.
┌─────────────────────────────────────┐
│ LOADING... (5 seconds) │
│ │
│ [Blank Screen] │
│ │
│ [User gets frustrated] │
│ │
│ [User clicks back] │
└─────────────────────────────────────┘
The human cost: 53% of mobile users abandon sites that take over 3 seconds to load. Your beautifully designed product page never gets seen.
The Solution: Show Something Immediately
Partial Pre-Rendering (PPR) flips this logic: Show users what you CAN show immediately, then fill in the rest.
The restaurant analogy:
User clicks "View Product"
↓
CDN serves pre-built static shell (0.3 seconds)
├─ Product title
├─ Images
├─ Description
├─ [Reviews skeleton]
└─ [Recommendations skeleton]
↓
USER SEES CONTENT IMMEDIATELY ✅
↓
Server fetches reviews (background) (1.2s)
↓
Server generates recommendations (background) (1.0s)
↓
Content streams in gradually
↓
Page fully loaded (1.5 seconds total)
Result: User sees product in 0.3s instead of 5s
How PPR Actually Works
The Two-Phase Approach
PPR splits your rendering into two distinct phases:
Phase 1: Build Time (Pre-Render Static Shell)
// At deployment (happens once)
export default function ProductPage({ productId }) {
// React identifies what's static vs dynamic
return (
<div>
{/* These pre-render at build time */}
<Header />
<ProductTitle productId={productId} /> // Static data
<ProductImages productId={productId} /> // Static data
<ProductDescription productId={productId} /> // Static data
{/* These stream at runtime */}
<Suspense fallback={<ReviewsLoading />}>
<ProductReviews productId={productId} /> // Dynamic data
</Suspense>
<Suspense fallback={<RecommendationsLoading />}>
<Recommendations userId={userId} /> // Dynamic data
</Suspense>
<Footer />
</div>
);
}
What happens at build time:
Phase 2: Request Time (Stream Dynamic Content)
User visits page
↓
CDN delivers static shell (0.3s) ← User sees content
├─ Product title ✅
├─ Images ✅
├─ Description ✅
├─ [Reviews loading...] ⏳
└─ [Recommendations loading...] ⏳
↓
Server fetches dynamic data (parallel)
↓
Reviews arrive (0.8s) → Fills placeholder
↓
Recommendations arrive (1.2s) → Fills placeholder
↓
Page fully interactive (1.5s total)
The Suspense Magic
The <Suspense> component is your marker for "this content needs data":
// Dynamic content with fallback
<Suspense fallback={<LoadingSkeleton />}>
<ProductReviews productId={productId} />
</Suspense>
// Or more sophisticated
<Suspense fallback={
<div className="reviews-loading">
<div className="review-card skeleton"></div>
<div className="review-card skeleton"></div>
<div className="review-card skeleton"></div>
</div>
}>
<ProductReviews productId={productId} />
</Suspense>
What Suspense does:
Recommended by LinkedIn
Real-World Architecture: E-Commerce Example
Before PPR: The Blocking Flow
// Old way - everything blocks
async function getProductPage(productId) {
// Wait for product data
const product = await fetchProduct(productId); // 200ms
// Wait for reviews
const reviews = await fetchReviews(productId); // 1200ms
// Wait for recommendations
const recs = await generateRecommendations(userId); // 1000ms
// Wait for pricing
const pricing = await getDynamicPricing(productId); // 800ms
// Render everything together
return renderProductPage(product, reviews, recs, pricing);
// Total: 3,200ms (3.2 seconds)
}
// User sees blank screen for 3.2 seconds
After PPR: Parallel Processing
// New way - static first, dynamic parallel
export default function ProductPage({ productId }) {
return (
<div>
{/* Static content - renders immediately */}
<StaticProductInfo productId={productId} />
{/* Dynamic content loads in parallel */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={productId} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations userId={userId} />
</Suspense>
<Suspense fallback={<PricingSkeleton />}>
<DynamicPricing productId={productId} />
</Suspense>
</div>
);
}
What happens behind the scenes:
Timeline:
0.3s: Static content ✅
0.8s: Reviews arrive ✅
1.2s: Recommendations arrive ✅
1.5s: Pricing arrives ✅
1.5s: Fully interactive ✅
Result: 5x faster perceived load time.
Performance Numbers: Before vs After
Core Web Vitals Improvement
Setting Up PPR: Step-by-Step
Step 1: Enable PPR in Your Framework
Next.js (Recommended):
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: 'incremental', // Enable Partial Pre-Rendering
missingSuspenseWithCSRBailout: 'swr'
}
};
module.exports = nextConfig;
Standalone React :
// App config
import { createRoot } from 'react-dom/client';
import { hydrateRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<App pprEnabled={true} />);
Step 2: Identify Your Static/Dynamic Boundaries
Audit your pages:
Static Content (Pre-render these):
Dynamic Content (Suspense these):
Step 3: Add Suspense Boundaries
// ❌ Bad: Everything in one block
function Dashboard() {
const [metrics, setMetrics] = useState([]);
const [chartData, setChartData] = useState([]);
return (
<div>
<Metrics metrics={metrics} />
<SalesChart data={chartData} />
</div>
);
}
// ✅ Good: Separate boundaries
function Dashboard() {
return (
<div>
{/* Static layout */}
<Header />
<Sidebar />
{/* Dynamic zones */}
<Suspense fallback={<MetricsSkeleton />}>
<Metrics />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
<Footer />
</div>
);
}
Step 4: Create Meaningful Loading States
Don't just use spinners—create realistic skeletons:
function ReviewsSkeleton() {
return (
<div className="reviews-container">
{[...Array(3)].map((_, i) => (
<div key={i} className="review-skeleton">
<div className="skeleton-avatar"></div>
<div className="skeleton-text">
<div className="skeleton-line"></div>
<div className="skeleton-line short"></div>
</div>
<div className="skeleton-rating"></div>
</div>
))}
</div>
);
}
function RecommendationsSkeleton() {
return (
<div className="recommendations-grid">
{[...Array(4)].map((_, i) => (
<div key={i} className="product-card-skeleton">
<div className="skeleton-image"></div>
<div className="skeleton-title"></div>
<div className="skeleton-price"></div>
</div>
))}
</div>
);
}
The Bottom Line
Partial Pre-Rendering isn't just a performance optimization—it's a fundamental improvement in user experience.
Instead of making users wait for your server to do everything, you give them immediate value while handling complexity in the background.
The transformation:
Implementation simplicity:
If you're using React 19.2, PPR should be your first performance initiative. The ROI is immediate and substantial.
For your next project: Identify your highest-traffic pages, add Suspense boundaries, and watch your engagement metrics transform.