I spent an afternoon debugging a component that kept re-fetching on every single request.
It had `'use cache'` right there in the code. I was confident it was working. It wasn't.
The problem was placement. `'use cache'` was on the wrapper function, not inside the actual data function. That one mistake makes Next.js ignore the directive entirely. No error, no warning, nothing in the terminal. Just a function running on every request when it should have been cached.
Another time I wrote this in a Server Action during a Next.js 16 migration:
```tsx
revalidateTag('products')
```
It compiled. It deployed. Pages stopped reflecting mutations. Calling `revalidateTag` without a second argument is a TypeScript error in Next.js 16, but the runtime fell back to legacy behaviour silently. I only caught it when users started reporting stale data.
**Next.js 16's new caching model is genuinely great. But during development it is a complete black box.** You add the directive, you assume it works, and you only find out otherwise when something breaks in production.
So I built a small dev-only toolkit to make it visible.
## What it catches
**1. Silent cache misses**
If a function runs more than once with identical arguments, you see this immediately:
```plaintext
[cache-debug] β POSSIBLE CACHE MISS - RE-EXECUTION WITH SAME ARGS
fn: getProductById
args: ["prod-123"]
This function ran 2 times with identical args.
If you expect caching: check 'use cache' is inside this function, not the wrapper.
```
That warning would have saved me that entire afternoon.
**2. Dynamic holes from short cacheLife**
`cacheLife('seconds')` silently excludes a component from the PPR static shell. It becomes fully dynamic. No warning anywhere, just a slower page that you cannot explain.
```plaintext
[cache-debug] β‘ DYNAMIC HOLE WARNING
fn: getLivePrice
cacheLife 'seconds' is short-lived (< 5 minutes or revalidate: 0).
Next.js 16 automatically EXCLUDES this from the PPR static shell.
This function will run at request time, it is NOT prerendered.
Fix: Use 'minutes' or longer if you want it in the static shell.
```
**3. Missing cacheTag**
A cached function with no `cacheTag()` can only expire by time. You cannot revalidate it on demand. Easy to miss when moving fast, painful to discover later.
```plaintext
[cache-debug] π· MISSING cacheTag WARNING
fn: getProductById
No cacheTag() found. This function cannot be invalidated on demand.
It will only expire when cacheLife runs out.
Fix: Add cacheTag('your-tag') inside the function.
```
**4. Deprecated revalidateTag**
In Next.js 16, `revalidateTag('tag')` without a second argument is a TypeScript error. The `logInvalidation` helper catches it before your CI does:
```plaintext
[cache-debug] β DEPRECATED revalidateTag - MISSING SECOND ARG
tag: products
revalidateTag('products') without a profile is deprecated in Next.js 16.
Fix: revalidateTag('products', 'max')
```
**5. updateTag outside a Server Action**
Calling `updateTag` outside a Server Action throws at runtime. The toolkit catches it at dev time before it reaches production.
**6. Repeated fetches**
`detectRepeatedFetch` surfaces the same URL being hit multiple times in one render. Usually means a cache layer is missing entirely.
## How to use it
**Step 1: Enable in `.env.local`**
```bash
CACHE_DEBUG=true
```
Do not add this to `.env.production`. The `NODE_ENV` guard already ensures it is off in production, but keeping env files clean is good practice.
**Step 2: Wrap your cached functions**
The `'use cache'` directive must stay inside the original function. `withCacheDebug` is a regular wrapper and cannot be a cache boundary. If you put `'use cache'` on the wrapper, the instrumentation gets cached instead of the data function, which is exactly the mistake the POSSIBLE CACHE MISS warning is designed to catch.
```tsx
import { cacheLife, cacheTag } from "next/cache";
import { withCacheDebug } from "@/lib/cache-debug";
async function _getProductById(id: string) {
"use cache";
cacheLife("hours");
cacheTag(`product-${id}`, "products");
return db.query("SELECT * FROM products WHERE id = $1", [id]);
}
export const getProductById = withCacheDebug(_getProductById, {
name: "getProductById",
cacheLife: "hours",
tags: ["product-{id}", "products"],
});
const product = await getProductById("prod-123");
```
Zero API change. The exported function works exactly the same everywhere you already call it.
**Step 3: Log invalidation calls in Server Actions**
```tsx
"use server";
import { revalidateTag, updateTag } from "next/cache";
import { logInvalidation } from "@/lib/cache-debug";
export async function updateProductPrice(id: string, newPrice: number) {
await db.query("UPDATE products SET price = $1 WHERE id = $2", [newPrice, id]);
logInvalidation("updateTag", `product-${id}`, {
isServerAction: true,
context: "admin price update",
});
updateTag(`product-${id}`);
logInvalidation("revalidateTag", "products", {
profile: "max",
isServerAction: true,
context: "admin price update",
});
revalidateTag("products", "max");
}
```
## Honest limitations
- **Process-scoped.** Execution maps reset on cold start. In serverless environments each invocation may be a fresh process, so you will only see re-execution data within the same warm instance. For local dev with a long-running server it works exactly as intended.
- **Best-effort concurrency.** Under concurrent rendering with identical args, both calls may log FIRST RUN rather than a miss. Detection does not affect correctness.
- **Cannot inspect Next.js internals.** The debugger counts executions to detect likely misses. It cannot read Next.js's internal cache store directly.
None of these affect production because the tool is not present there.
## At a glance
| What it detects | Without this tool |
|---|---|
| FIRST RUN / CACHE MISS / NEW KEY | Not visible |
| Dynamic hole from short cacheLife | Not visible |
| Missing cacheTag | Not visible |
| Deprecated revalidateTag | TypeScript error, easy to miss |
| updateTag outside Server Action | Runtime throw |
| Repeated fetches in one render | Not visible |
Zero external dependencies. TypeScript 5.0+ with strict mode. Next.js 16 only -- `updateTag` does not exist in Next.js 15. Double-gated on `NODE_ENV === 'development'` AND `CACHE_DEBUG=true` so nothing ships to production. No overhead, no bundle impact.
## Get it
Free forever, one `.tsx` file: [shubhra.dev/snippets/nextjs-use-cache-debugger](https://shubhra.dev/snippets/nextjs-use-cache-debugger)
If you want the production enforcement layer that pairs with this -- type-safe tag registry, `safeRevalidate` that blocks the deprecated single-arg call at compile time, `serverActionInvalidate` that enforces the correct invalidation order -- that is the [Cache Pro Kit](https://shubhra.dev/snippets/nextjs-cache-pro).
If you are new to the Next.js 16 caching model and want to understand what `'use cache'`, `cacheLife`, and `cacheTag` are actually doing before using this toolkit, the [practical migration guide](https://shubhra.dev/tutorials/nextjs-16-cache-components) covers the full picture. There is also a [15-question quiz](https://shubhra.dev/quiz/nextjs-16-cache-components) if you want to test your understanding.
If you are in the middle of a Next.js 16 caching migration and something is behaving unexpectedly, this will make it visible. What has been the most frustrating or confusing part of the new caching model for you?