Next.js 15 Caching Burned My Users Twice Before I Understood the Model

typescript dev.to

My production app was showing users stale data 8 hours after I shipped a hotfix. The Next.js cache was working exactly as documented. I just didn't understand what I'd opted into.

This happened twice before I mapped out all four cache layers.

The four cache layers

Next.js 15 has four distinct caches. Treating them as one is how you get my problem.

1. Request memoization — deduplicates identical fetch() calls within a single render pass. Automatic, can't disable it.

2. Data cache — persists fetched data across requests and deployments. This is the one that burned me. fetch() defaults to this in Server Components.

3. Full Route cache — caches rendered HTML of static routes. Invalidated by revalidatePath() or redeployment.

4. Router cache — client-side cache of RSC payloads in the browser session. Expires after 30s (dynamic) or 5min (static).

When I deployed my hotfix, the database was updated. But the Full Route cache had the old HTML, and the Data cache had the old fetch result. Users saw the stale page.

What fetch() does by default

In a Server Component, this:

export default async function ProductsPage() {
  const res = await fetch('https://api.example.com/products');
  const products = await res.json();
  return <ProductList products={products} />;
}
Enter fullscreen mode Exit fullscreen mode

defaults to { cache: 'force-cache' } — cached indefinitely in the Data cache. I was calling my own internal API and expected fresh data. It was frozen at the time of the first request.

The fix for dynamic data

// Never cache — always hit origin
const res = await fetch('https://api.example.com/products', {
  cache: 'no-store'
});

// Cache with time window (ISR)
const res = await fetch('https://api.example.com/products', {
  next: { revalidate: 60 }  // revalidate every 60 seconds
});

// Opt entire route into dynamic rendering
export const dynamic = 'force-dynamic';
Enter fullscreen mode Exit fullscreen mode

For dashboard pages where data changes frequently: cache: 'no-store'. For content pages where slight staleness is acceptable: revalidate: 60.

On-demand invalidation with cache tags

When a user action changes data, you want immediate invalidation:

// Tag the fetch
const res = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] }
});

// Invalidate by tag in a Server Action
import { revalidateTag } from 'next/cache';

export async function updateProduct(id: string, data: ProductUpdate) {
  await db.update(products).set(data).where(eq(products.id, id));
  revalidateTag('products');  // surgical — only invalidates tagged fetches
}
Enter fullscreen mode Exit fullscreen mode

revalidateTag() is surgical. revalidatePath() is the nuclear option — invalidates everything for a route.

The escape hatch for nested components

If you can't thread cache: 'no-store' through a call chain:

import { unstable_noStore as noStore } from 'next/cache';

export async function DashboardMetrics() {
  noStore();  // this component always renders dynamically
  const metrics = await db.query.metrics.findMany({ limit: 10 });
  return <MetricsGrid data={metrics} />;
}
Enter fullscreen mode Exit fullscreen mode

The Router cache is different — it's in the browser

The second burn: users on an existing session still saw old data in routes they'd already visited. The Router cache stores RSC payloads on the client and doesn't clear on server deploys.

For mutations users need to see immediately:

'use client';
import { useRouter } from 'next/navigation';

export function DeleteButton({ productId }: { productId: string }) {
  const router = useRouter();

  async function handleDelete() {
    await deleteProduct(productId);  // Server Action
    router.refresh();  // clears Router cache for current route
  }

  return <button onClick={handleDelete}>Delete</button>;
}
Enter fullscreen mode Exit fullscreen mode

The mental model

Think of it as layers from database to browser:

Database → Data cache (server, persistent)
         → Full Route cache (server, HTML)
         → Router cache (client, session)
         → Browser
Enter fullscreen mode Exit fullscreen mode

Changing the database invalidates nothing above it automatically. You must tell each layer.

One line: if data changes, use cache: 'no-store' or set revalidate. Never assume a Server Component fetch is live.


The Next.js 15 starter I use has this pre-configured — no-store for dashboard routes, cache tags for on-demand invalidation, ISR for content:

AI SaaS Starter Kit — whoffagents.com

More AI tools → whoffagents.com

Source: dev.to

arrow_back Back to Tutorials