DEV Community

Next.js Caching Is Hard Because You’re Thinking About It Too Late

Modern Web Development in 2026

A practical series about building faster, cleaner, more maintainable web applications without chasing every shiny thing.

Next.js caching is not hard because caching is mysterious.

It is hard because teams often decide what the user should see, build the feature, ship the route, and only then ask:

Wait, should this be cached?

By that point, caching feels like a trap. Static or dynamic? Revalidate or no-store? Tag invalidation? Server Actions? Partial rendering? Why did this page update locally but not in production?

The problem started earlier.

Caching is not a setting. It is a product decision.

Start with freshness

Before writing code, classify the data.

Always fresh        account balance, auth state, checkout price
Fresh enough        inventory count, dashboard cards, notifications
Rarely changes      marketing copy, docs navigation, pricing page layout
Versioned           blog posts, changelog entries, release notes
User-triggered      form result, optimistic mutation, saved settings
Enter fullscreen mode Exit fullscreen mode

Each category deserves a different strategy.

If you do not define freshness, the framework cannot guess your intent.

The user does not care about your cache

Users care about expectations:

  • β€œI changed my profile; I should see it now.”
  • β€œI published a post; the public page should update soon.”
  • β€œI added an item to cart; the total should be correct.”
  • β€œI opened docs; they should load instantly.”

Those expectations map directly to caching behavior.

Cache by boundary, not by hope

A page usually contains mixed data.

Example product page:

  • product title: rarely changes,
  • price: may change,
  • inventory: fresh enough,
  • recommendations: personalized,
  • reviews: revalidated periodically,
  • cart state: user-specific.

If you cache the entire page as one unit, one dynamic section can make the whole route harder to reason about.

Instead, design boundaries:

export default async function ProductPage({ params }) {
  return (
    <main>
      <ProductShell productId={params.id} />
      <LivePurchaseBox productId={params.id} />
      <Recommendations productId={params.id} />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Then each section can have its own freshness model.

Make invalidation boring

Invalidation should be obvious from the mutation.

Bad mutation:

await updateProduct(productId, input);
Enter fullscreen mode Exit fullscreen mode

Better mutation:

await updateProduct(productId, input);
await invalidateProduct(productId);
await invalidateProductList();
Enter fullscreen mode Exit fullscreen mode

Even better: centralize the policy so developers do not remember tags by hand.

export async function invalidateProduct(productId: string) {
  revalidateTag(`product:${productId}`);
  revalidateTag("products:list");
}
Enter fullscreen mode Exit fullscreen mode

A cache strategy nobody can remember is not a strategy.

Avoid accidental personalization leaks

Caching becomes dangerous when user-specific data slips into shared output.

Watch for:

  • user names in cached navigation,
  • role-specific actions in shared HTML,
  • personalized recommendations in static shells,
  • auth-only pricing mixed with public product data.

A simple rule helps:

Public data can be shared. User data needs a user boundary.

If a component depends on cookies, headers, session, or permissions, treat it differently.

Use loading states intentionally

Partial rendering and streaming are powerful because they let you show stable UI while dynamic sections load.

But a loading skeleton is not a substitute for product thinking.

Good skeleton:

  • preserves layout,
  • communicates progress,
  • appears only where delay is expected,
  • does not cause content to jump.

Bad skeleton:

  • flashes for 80ms,
  • shifts the layout,
  • appears everywhere,
  • hides the fact that the data model is too slow.

Debugging questions

When a Next.js route behaves strangely, I use this checklist:

## Caching debug checklist

- Is this route expected to be static, dynamic, or mixed?
- Which data must be fresh for every request?
- Which data can be cached safely?
- Is any user-specific data crossing a shared cache boundary?
- What event invalidates this data?
- Does the mutation revalidate the exact thing the user expects?
- Is the loading state masking an architecture problem?
- Can the behavior be explained in one paragraph?
Enter fullscreen mode Exit fullscreen mode

That last question matters. If nobody on the team can explain the caching behavior simply, production will explain it for you.

The mental model

Think of caching as a conversation between three things:

  1. Data truth β€” where the correct data lives.
  2. User expectation β€” when the user expects to see changes.
  3. Rendering boundary β€” where the UI can safely reuse work.

When those three agree, caching feels boring.

When they disagree, caching feels haunted.

Final thought

Next.js caching is not something to sprinkle on a finished route.

It belongs in the first design conversation.

Decide freshness early. Draw boundaries early. Name invalidation early.

Your future self will spend less time asking why the page is stale and more time building things users actually notice.

Sources


Thanks for reading.

You can find me here:

Top comments (0)