Last update
1. Async Component = Promise
The most important concept to understand:
// This async component RETURNS a promise
async function ProductStock({ productId }) {
const stock = await db.getStock(productId);
return <div>{stock}</div>;
}
// That's why the Suspense goes in the PARENT:
function Parent() {
return (
<Suspense fallback="Loading...">
<ProductStock /> {/* ← This line CREATES and EXECUTES the promise */}
</Suspense>
);
}When React renders <ProductStock />, it is invoking an async function, which creates a promise. The Suspense in the parent captures that promise.
Common Error: Trying to put Suspense INSIDE the async component.
❌ Incorrect
async function ProductStock() {
return (
<Suspense> {/* The await has already happened, this won't work */}
<div>{await db.query(...)}</div>
</Suspense>
)
}Why it doesn't work:
- The
awaitexecutes BEFORE thereturn. - By the time you reach the
return, the promise has already been resolved. - Suspense has nothing to capture.
✅ Correct
function Parent() {
return (
<Suspense fallback="Loading...">
<ProductStock /> {/* Suspense captures the execution of the component */}
</Suspense>
)
}
async function ProductStock() {
const stock = await db.query(...)
return <div>{stock}</div>
}2. Multiple Queries vs. Cache
The Common Question
"Isn't it inefficient to make 3 separate queries for the same product?"
Answer: It depends on when they are executed.
Traditional Model
// One query in each request
async function ProductPage({ productId }) {
const product = await db.query('SELECT * FROM products')
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<p>Stock: {product.stock}</p>
</div>
)
}Timeline:
- Request 1: Complete query
- Request 2: Complete query
- Request 3: Complete query
Total: 3 queries to the DB
Granular Model
// 3 queries, but only 1 at request time
function ProductPage({ productId }) {
return (
<>
<ProductText productId={productId} /> {/* Build time */}
<ProductPrice productId={productId} /> {/* Build time */}
<Suspense>
<ProductStock productId={productId} /> {/* Request time */}
</Suspense>
</>
)
}Timeline:
- Build: 2 queries (text + price)
- Request 1: 1 query (stock)
- Request 2: 1 query (stock)
- Request 3: 1 query (stock)
Total: 2 queries at build + 3 queries at request = 5 queries
But each request only executes 1 small query, and the HTML comes pre-generated.
Conscious Trade-off
| Aspect | Single Query | Separate Queries |
|---|---|---|
| Total Queries | 3 (1 per request) | 5 (2 at build + 3 at request) |
| Queries per Request | 1 complete query | 1 small query (stock) |
| Granular Control | ❌ All or nothing | ✅ Per field |
| Perceived Performance | Depends on total cache | Instant HTML + streaming |
| Complexity | Low | Medium |
Conclusion: More total queries, but better user experience and fine-grained control.
3. Tags for Revalidation
Tags allow for selective and precise cache invalidation.
Without Tags (Traditional)
// Invalidate the entire page
revalidatePath("/product/1");
// Invalidates THE WHOLE product:
// - Text (rarely changes)
// - Price (the only one that changed)
// - Stock (which already has no cache)With Tags (Granular)
// Cache with unique tags
async function ProductPrice({ productId }) {
"use cache";
cacheTag(`product-price-${productId}`);
// ...
}
// Invalidate only the price
revalidateTag(`product-price-${productId}`, "max");
// Text keeps its cache ✅
// Stock already has no cache ✅Anatomy of a Tag
cacheTag(`product-price-${productId}`);
// Final Tag: "product-price-1"
// Allows: revalidateTag('product-price-1', 'max')Advantage: Surgical revalidation without affecting other fields.
4. Runtime Data Requires Suspense
Runtime data is information that only exists when a request arrives:
params- URL parameterssearchParams- Query stringscookies()- User cookiesheaders()- Request headers
Why They Need Suspense
// ❌ This triggers an error
export default async function ProductPage({ params }) {
const { id } = await params; // Runtime data without Suspense
return <div>{id}</div>;
}
// Error: Runtime data was accessed outside of <Suspense>Reason: During prerendering (build time), params does not exist. Next.js requires you to explicitly mark that this content needs a request context.
Solution
// ✅ Correct
export default function ProductPage({ params }) {
return (
<Suspense fallback={<Skeleton />}>
<ProductContent params={params} /> {/* params passed as a prop */}
</Suspense>
)
}
async function ProductContent({ params }) {
const { id } = await params {/* Now it IS inside Suspense */}
return <div>{id}</div>
}The pattern is: a sync component passes runtime data as a prop to an async component inside Suspense.
5. Static Shell vs. Streaming
Static Shell
Content that is prerendered and included in the initial HTML:
// These components with 'use cache' generate static shell
<ProductText productId={id} /> // → HTML included
<ProductPrice productId={id} /> // → HTML includedAdvantage: The user sees content instantly, without waiting for queries.
Streaming
Content that is rendered at request time and streamed to the browser:
// This component without 'use cache' streams
<Suspense fallback={<Skeleton />}>
<ProductStock productId={id} /> // → Streams in later
</Suspense>Advantage: Content is always fresh without blocking the initial HTML.
Visualization
User Request → Server
↓
[Prerendering check]
↓
┌──────────┴──────────┐
↓ ↓
Static Shell Streaming
(instant) (on-demand)
↓ ↓
Browser ←────────────────┘
↓
[Shows static + skeleton]
↓
[Streaming replaces skeleton]
↓
[Complete page rendered]6. cacheLife Profiles
Predefined profiles for different types of content:
cacheLife("default"); // General balance
cacheLife("minutes"); // Volatile (e.g., trending topics)
cacheLife("hours"); // Changes occasionally (e.g., prices)
cacheLife("days"); // Changes rarely (e.g., categories)
cacheLife("weeks"); // Very stable (e.g., legal terms)
cacheLife("max"); // Almost never changes (e.g., historical content)Or define your own profile:
cacheLife({
stale: 3600, // 1 hour until considered stale
revalidate: 7200, // 2 hours until revalidation
expire: 86400, // 1 day until complete expiration
});Rule of thumb: Use the longest profile acceptable for your use case. More cache = better performance.