BlogCache Components

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 await executes BEFORE the return.
  • 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

Conscious Trade-off

AspectSingle QuerySeparate Queries
Total Queries3 (1 per request)5 (2 at build + 3 at request)
Queries per Request1 complete query1 small query (stock)
Granular Control❌ All or nothing✅ Per field
Perceived PerformanceDepends on total cacheInstant HTML + streaming
ComplexityLowMedium

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 parameters
  • searchParams - Query strings
  • cookies() - User cookies
  • headers() - 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 included

Advantage: 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.

On this page