Production caching utilities that make Next.js 16 caching production-safe. Type-safe tag registry, dual-invalidation for Server Actions, parallel prefetching, and Suspense boundary enforcement. One file, zero guessing.
- Views
- Likes
shubhra.dev
Loading...
A dev-only toolkit that instruments your 'use cache' functions with zero production cost. Catch cache misses, dynamic holes, missing tags, and deprecated invalidation calls. All output goes straight to your terminal.
Click any scenario to see what cache-debug outputs to your terminal. All output is dev-only, zero cost in production.
Click a scenario above to simulate terminal output
CACHE_DEBUG=true · NODE_ENV=development
Double-gated on NODE_ENV and CACHE_DEBUG. The original function is returned untouched in production, no overhead, no bundle impact.
Short cacheLife profiles (seconds, expire < 5min) are flagged before they silently break your PPR static shell in production.
Functions without cacheTag() can never be invalidated on demand. The toolkit warns you right away so you don't discover this at 2am.
detectRepeatedFetch surfaces the same URL being called multiple times in one render, a reliable sign that a cache layer is missing.
// Loading...src/components/Love this snippet?
Share it with your friends and colleagues
Get practical tutorials, engineering insights, and new developer resources delivered.
One-click confirmation required. No spam. Unsubscribe anytime.
Help keep Shubhra.dev creating free tutorials, articles, snippets, quizzes, and developer resources for developers everywhere.
Support shubhra.devBrowse production-ready hooks, components, and utilities, built for serious developers.
Browse All SnippetsProduction caching utilities that make Next.js 16 caching production-safe. Type-safe tag registry, dual-invalidation for Server Actions, parallel prefetching, and Suspense boundary enforcement. One file, zero guessing.
A zero-dependency React hook for infinite scroll. Intersection Observer, AbortController-safe reset, smart retry, and React 18+ Strict Mode support.
Next.js 16's 'use cache' directive is powerful, but completely opaque during development. You add it to a function, you assume it caches, and you only find out it doesn't when a user reports a slow page or your DB starts showing unexpected query volume.
// ❌ What you have to work with today
async function getProducts() {
"use cache";
cacheLife("seconds"); // is this creating a dynamic hole? no idea
// no cacheTag, can never revalidate on demand, but no warning
return db.query("SELECT * FROM products");
}
// Is this deprecated? Will it throw in production?
revalidateTag("products"); // TypeScript error in Next.js 16, but silentThere's no built-in way to confirm a function is actually being cached, detect that cacheLife('seconds') has excluded it from the static shell, or catch that revalidateTag() without a second argument is now a TypeScript error.
New to Next.js 16 caching? Read the Next.js 16 Cache Components: Practical Migration & Implementation Guide first - this debugger makes more sense once you understand what
'use cache',cacheLife, andupdateTagare doing.
Server functions with 'use cache' run at build time, at request time, or straight from cache. Without instrumentation you genuinely cannot tell which is happening. Here's what stays invisible without tooling:
'use cache' was put on the wrapper instead of inside the function. No error. Just slow.revalidateTag('tag') without a profile is a TypeScript error in Next.js 16 that compiles fine in older setups and silently falls back to legacy behaviour.cacheLife('seconds') causes Next.js to exclude a component from the PPR static shell entirely. The page becomes fully dynamic. Nothing in the terminal tells you.cacheTag() call can only expire based on time. You can't revalidate it on demand. If you never added tags, you have no idea you're locked out until you need to clear it.This toolkit makes all of it visible in your terminal, in development only, with zero cost in production.
| Feature | cache-debug | No Tooling |
|---|---|---|
| FIRST RUN / CACHE MISS / NEW KEY detection | ✅ | ❌ |
| Dynamic hole warning (short cacheLife) | ✅ | ❌ |
| Missing cacheTag warning | ✅ | ❌ |
| Deprecated revalidateTag detection | ✅ | ❌ |
| updateTag outside Server Action caught | ✅ | ❌ |
| Repeated fetch detection | ✅ | ❌ |
| updateTag reminder for user-specific data | ✅ | ❌ |
| Map size cap (no memory growth in dev) | ✅ | ❌ |
| Zero production cost (NODE_ENV hard gate) | ✅ | ✅ |
| No external dependencies | ✅ | ✅ |
NO_COLOR=true for CI environments.NODE_ENV === 'development' AND CACHE_DEBUG === 'true'. The original function is returned untouched in production.cacheLife('seconds') or expire < 5m triggers a specific warning that the function will be excluded from the PPR static shell.cacheTag() can't be invalidated on demand. The toolkit tells you before you hit this in production.detectRepeatedFetch surfaces the same URL being fetched multiple times in one render, which is a reliable sign a cache layer is missing.resetDebugMaps() clears all state between Vitest/Jest tests. No bleed-through.# .env.local
CACHE_DEBUG=true
# Optional: disable ANSI colours in CI
# NO_COLOR=trueDo not add CACHE_DEBUG=true to .env.production. The NODE_ENV guard already ensures it's off, but keeping your env files clean is good practice.
// lib/data/products.ts
import { cacheLife, cacheTag } from "next/cache";
import { withCacheDebug } from "@/lib/cache-debug";
// STEP 1: Write the function with 'use cache' INSIDE
async function _getProductById(id: string) {
"use cache"; // ← always inside, never on the wrapper
cacheLife("hours");
cacheTag(`product-${id}`, "products");
return db.query("SELECT * FROM products WHERE id = $1", [id]);
}
// STEP 2: Export the debugged version
export const getProductById = withCacheDebug(
Terminal output on first call:
[cache-debug] ▶ FIRST RUN
fn: getProductById
args: ["prod-123"]
note: This always executes, it's warming the cache
[cache-debug] ✓ getProductById completed in 12.4ms
On a second call with the same args (Next.js is correctly caching, so the function won't re-run unless the cache expires):
[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.
async function _getLivePrice(productId: string) {
"use cache";
cacheLife("seconds"); // ⚠️ short lifetime
cacheTag(`price-${productId}`);
return fetch(`/api/prices/${productId}`).then((r) => r.json());
}
export const getLivePrice = withCacheDebug(_getLivePrice, {
name: "getLivePrice",
cacheLife: "seconds", // ← pass the same profile
tags: ["price-{productId}"],
});Terminal:
[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.
export const getUserProfile = withCacheDebug(_getUserProfile, {
name: "getUserProfile",
cacheLife: "hours",
tags: ["user-{userId}"],
isUserSpecific: true, // ← enables updateTag reminder
});Terminal:
[cache-debug] 👤 USER-SPECIFIC CACHE DETECTED
fn: getUserProfile
tags: user-{userId}
After mutation in a Server Action:
→ updateTag(tag) for immediate UI update (acting user)
→ revalidateTag(tag, 'max') for all other readers (SWR)
Call logInvalidation immediately before every revalidateTag or updateTag call in your Server Actions. It catches the deprecated single-argument form and surfaces semantics:
// app/actions/products.ts
"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",
Terminal:
[cache-debug] updateTag
tag: product-prod-123
context: admin price update
effect: Immediate expiry + re-fetch within same request
note: Acting user sees fresh data. Other routes update on next visit.
[cache-debug] revalidateTag
tag: products
profile: 'max'
context: admin price update
effect: Stale-while-revalidate, readers may briefly see stale content
// Missing second argument (TypeScript error in Next.js 16)
logInvalidation("revalidateTag", "posts", { isServerAction: true });
// ↑ No profile → error logged before the TS compiler catches itTerminal:
[cache-debug] ✗ DEPRECATED revalidateTag — MISSING SECOND ARG
tag: posts
revalidateTag('posts') without a profile is deprecated in Next.js 16.
This produces a TypeScript error and uses legacy immediate-expiry behavior.
Fix: revalidateTag('posts', 'max')
// lib/data/posts.ts
import { detectRepeatedFetch } from "@/lib/cache-debug";
export async function getLatestPosts() {
return detectRepeatedFetch(
() => fetch("https://api.example.com/posts").then((r) => r.json()),
"GET /api/posts",
);
}If called more than once in one render:
[cache-debug] 🔁 REPEATED FETCH (2×) — POSSIBLE MISSING CACHE
label: GET /api/posts
This fetch ran 2 times in this render.
If data doesn't change per-request: add 'use cache' + cacheLife() to the function.
If it must be fresh every time: this is expected, no change needed.
| Option | Type | Default | Description |
|---|---|---|---|
name | string | fn.name | Label shown in terminal logs. Set this explicitly since anonymous functions are hard to track. |
cacheLife | CacheLifeProfile | Enables dynamic hole warning for short lifetimes (< 5min or revalidate: 0). | |
tags | string[] | Enables missing-tag warning if empty. Human-readable hints only. | |
isUserSpecific | boolean | false | Enables updateTag reminder for user-scoped cached data. |
| Param | Type | Description |
|---|---|---|
method | 'revalidateTag' | 'updateTag' | Which invalidation call follows this log. |
tag | string | The cache tag being invalidated. |
profile | string | { expire?: number } | Profile passed to revalidateTag. Omitting triggers the deprecated-call error. |
context | string | Human-readable context shown in logs. |
isServerAction | boolean | false triggers the "updateTag outside Server Action" error. |
| Param | Type | Description |
|---|---|---|
fetchFn | () => Promise<T> | The actual fetch call to execute. |
label | string | A human-readable identifier (e.g. 'GET /api/products'). |
No parameters. Clears both executionMap and fetchCallMap. No-op in production. Call in beforeEach() in Vitest/Jest to prevent state bleed between tests.
withCacheDebug is a regular async function and cannot be a Next.js cache boundary. The 'use cache' directive must be in the function that Next.js compiles into a cache entry. Putting it on the wrapper means the instrumentation layer is cached (doing nothing), not the data function.
The wrapper detects this mistake: if the same function runs twice with identical args and withCacheDebug is wrapping the cached call, you'll see POSSIBLE CACHE MISS in the terminal right away.
Each call increments a counter in executionMap and records the argument fingerprint. On every call after the first:
'use cache' is on the wrapper (wrong), the cache expired, or the entry was evicted.This is best-effort. In concurrent renders with the same fn and args, both calls may log FIRST RUN rather than a miss. The detection doesn't affect correctness.
Both maps are capped at 500 entries. On cap, the oldest entry (first by Map insertion order) is evicted before the new one is written. This prevents unbounded memory growth in long-running dev servers with many unique function labels or fetch URLs. In Vitest/Jest, call resetDebugMaps() in beforeEach() to prevent stale entries from one test affecting the next.
fingerprint() runs JSON.stringify with a custom replacer that handles circular references, functions, Promises, and Errors without throwing. Output is truncated at 500 characters. This is debug-only, so the performance cost of serialising args on every call is acceptable in development.
The c object maps every severity to an ANSI escape code. Set NO_COLOR=true in your CI environment to disable all colour output. Every code path falls back to empty strings, so log output remains readable in plain text.
Tested against Next.js 16.2 (April 2026). Requires cacheComponents: true in next.config.ts.
Process-scoped maps. executionMap and fetchCallMap reset on cold start. In serverless environments (Vercel Functions), each invocation may be a fresh process, so you'll only see re-execution data within the same warm instance. For local dev with a long-running Next.js server, the maps persist across requests exactly as intended.
Concurrency. The cache miss detection is best-effort. Under concurrent rendering with identical args, both calls may log FIRST RUN. That's a reasonable trade-off for a debugging tool.
'use cache' placement. The debugger can detect a likely miss but cannot inspect Next.js's internal cache store. If you're seeing POSSIBLE CACHE MISS warnings and are certain the cache should be hitting, verify 'use cache' is inside the original function and not on the wrapper.
| Framework | Support |
|---|---|
| Next.js 16.0+ | ✅ |
| Next.js 15 | ❌ (updateTag does not exist) |
| TypeScript strict | ✅ |
| Metric | Value |
|---|---|
| Dependencies | 0 (Node.js built-ins only) |
| Bundle size | 0 (dev-only, never shipped) |
| Production cost | Zero (hard NODE_ENV gate) |
# 1. Add to .env.local (never .env.production)
CACHE_DEBUG=true
# 2. Optional: disable colour in CI terminals
NO_COLOR=true// 3. Wrap each cached function
export const myFn = withCacheDebug(_myFn, {
name: "myFn",
cacheLife: "hours",
tags: ["my-tag"],
});
// 4. Add logInvalidation before every revalidateTag / updateTag
logInvalidation("revalidateTag", tags.products, {
profile: "max",
isServerAction: true,
});
revalidateTag("products", "max");
// 5. Vitest/Jest: reset between tests
beforeEach(() => resetDebugMaps());Production deployment: wrappers are zero-cost no-ops. Keep them in your codebase or remove them, there is no production impact either way.
revalidateTag() without a profile is now a TypeScript error. logInvalidation catches every occurrence before your CI does.cacheLife profiles silently break the static shell. The warning tells you which function is responsible before it reaches production.isUserSpecific flag reminds you to use updateTag in Server Actions so the acting user sees their change immediately.Next.js 16 Cache Pro Kit - paid. The production enforcement layer that pairs with this debugger. While this toolkit shows you what's happening in development, the Pro Kit makes the wrong patterns impossible at the type level — type-safe tag registry, safeRevalidate that blocks the deprecated single-arg call at compile time, and serverActionInvalidate that enforces the correct updateTag → revalidateTag order every time.
Next.js 16 Cache Components Quiz - free. 15 questions on use cache, cacheLife, revalidateTag vs updateTag, and PPR. Good check that you understand what this toolkit is warning you about.
Next.js 16 Cache Components: Practical Migration Guide - free. The full migration walkthrough this toolkit was built alongside.
Next.js 16's caching model is the right architecture. 'use cache' with cacheLife and cacheTag is a huge improvement over the fetch cache and getStaticProps patterns. The problem is that it's completely invisible during development.
I spent an afternoon debugging a component that was re-fetching on every request despite having 'use cache' on it. The directive was on the wrapper. The function inside wasn't cached at all. There was no warning, just slower responses than expected and a lot of head-scratching.
logInvalidation came from a different incident: a team member wrote revalidateTag('products') in a Server Action during the migration to Next.js 16. It compiled, deployed, and silently used legacy behaviour until we noticed pages weren't updating on mutation.
This toolkit makes both problems impossible to miss.