DEV Community

Cover image for I built a free debugger because Next.js 16 'use cache' was completely invisible during development
Shubhra Pokhariya
Shubhra Pokhariya

Posted on • Edited on • Originally published at shubhra.dev

I built a free debugger because Next.js 16 'use cache' was completely invisible during development

Catches silent revalidation and placement errors

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:

revalidateTag('products')
Enter fullscreen mode Exit fullscreen mode

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:

[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.
Enter fullscreen mode Exit fullscreen mode

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.

[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.
Enter fullscreen mode Exit fullscreen mode

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.

[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.
Enter fullscreen mode Exit fullscreen mode

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:

[cache-debug] โœ—  DEPRECATED revalidateTag - MISSING SECOND ARG
  tag:     products
  revalidateTag('products') without a profile is deprecated in Next.js 16.
  Fix: revalidateTag('products', 'max')
Enter fullscreen mode Exit fullscreen mode

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

CACHE_DEBUG=true
Enter fullscreen mode Exit fullscreen mode

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.

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");
Enter fullscreen mode Exit fullscreen mode

Zero API change. The exported function works exactly the same everywhere you already call it.

Step 3: Log invalidation calls in Server Actions

"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");
}
Enter fullscreen mode Exit fullscreen mode

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

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.

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 covers the full picture. There is also a 15-question quiz 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?

Top comments (28)

Collapse
 
mudassirworks profile image
Mudassir Khan

the wrapper placement bug bit us mid migration โ€” use cache on the outer function, not inside the data function. spent an afternoon in Next.js DevTools wondering why server components were still showing stale data. the directive was right there, compiled fine, ran in prod.

the PPR dynamic hole from cacheLife('seconds') is the one i'd add to every migration checklist though. silently drops a component from the static shell with zero warning. we saw it in LCP metrics before we found it in the code.

does withCacheDebug play nicely with React's cache() for deduplication, or does the instrumentation layer break the memoization?

Collapse
 
shubhradev profile image
Shubhra Pokhariya

That wrapper placement one is brutal. Everything looks correct, compiles fine, and just silently does nothing. Definitely one of those you only learn after getting burned once.

The cacheLife('seconds') PPR drop is the same story. We hit it during migration and only noticed it through LCP changes in prod. Feels like something that should be part of every checklist.

On withCacheDebug + React cache(): since the wrapper sits outside the cache boundary, it shouldnโ€™t break deduplication. It just logs executions. Still worth sanity checking with CACHE_DEBUG=true to make sure youโ€™re seeing the expected single execution per render.

Collapse
 
mudassirworks profile image
Mudassir Khan

The LCP change discovery is the right signal โ€” cache bugs tend to appear in perf metrics before errors log anything. We added CACHE_DEBUG=true to our checklist after the same lesson.

One thing i'm still unsure about: when withCacheDebug reports execution count, is that tracking calls before React cache deduplication kicks in, or after? Our numbers felt off on an ISR route and couldn't tell if it was a dedup miss or a misread counter.

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

Good question, I had to double check this myself at one point.

withCacheDebug sits outside 'use cache' in practice. It wraps the raw function and counts every call attempt before Next.js caching comes into play. So you can still see multiple execution logs even when the underlying data function only runs once due to React cache deduplication.

On ISR routes it gets a bit noisier because revalidation cycles can look like repeated executions, when they are actually separate request lifecycles.

What helped me was adding a log inside the actual data function for comparison. If inner logs are lower than withCacheDebug, dedup is working and the wrapper is just counting call attempts. If they match, something in the caching path is not being applied as expected.

Thread Thread
 
mudassirworks profile image
Mudassir Khan

the 'count call attempts, not executions' framing clicks for me. we had a case where execution count stayed at 1 but withCacheDebug showed 6 โ€” spent an embarrassing amount of time hunting a bug in the cache config instead of just trusting dedup was working.

the ISR noise thing is the next one to dig into. do you strip revalidation cycles from your comparison, or just accept the count mismatch as expected on ISR paths?

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

I just accept the ISR noise honestly. Once you know itโ€™s counting call attempts not executions, the mismatch stops being confusing. Reading the wrapper count alongside the inner function logs is more useful than trying to filter anything out.

Thread Thread
 
mudassirworks profile image
Mudassir Khan

makes sense โ€” accepting the count mismatch is probably the right call once you've internalized what it means. the 'wrapper + inner function' pairing as a mental model is solid.

one edge case we hit: on staggered ISR with different revalidate intervals, the mismatch gets louder and starts looking like a real bug again. did you find anything in the debugger that helps distinguish 'expected ISR noise' from an actual cache miss?

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

Staggered ISR is the one case where the count mismatch actually starts looking like a real bug.

I didn't build anything special into the debugger for it. The wrapper still just counts call attempts, and with different revalidate intervals those cycles stack up quickly, so the numbers get noisy.

What helped me was looking at timestamps instead of counts. If executions are milliseconds apart, that's usually concurrent rendering. If they're spaced out and line up with your revalidate intervals, that's ISR. Cache misses usually show up as rapid repeated executions with the same inputs.

I also tried setting all intervals to the same value temporarily just to reduce the noise. Once the mismatch settled down, it was clear the staggered ISR was the cause. After that it stopped feeling like noise and just became expected behavior.

Thread Thread
 
mudassirworks profile image
Mudassir Khan

the timestamp diff approach is cleaner than i expected โ€” milliseconds apart for concurrent, spread to match revalidate cadence for ISR. way better signal than fighting the raw count.

the 'flatten all intervals temporarily' trick is going into the debugging runbook. never thought to normalize first; makes the distinction immediately obvious.

did you ever see cache misses show up as clustered timestamps with widely spaced inputs? that edge case still trips me up.

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

Yeah I've seen something close to that. When inputs differ but timestamps cluster, it's often not a pure cache miss but multiple render paths hitting the same function with slightly different params before dedup kicks in.

What helped me was logging the normalized cache key (or whatever identifies the request) instead of just inputs. If keys differ, it's expected. If keys are identical and still firing rapidly, that's when I start suspecting an actual miss.

That edge case is tricky though, one of those where logs matter more than the counters.

Thread Thread
 
mudassirworks profile image
Mudassir Khan

the normalized cache key distinction is the cleanest way to separate expected dedup from actual miss i've seen. we had a case where the key included a stale session hash that kept rotating โ€” looked like a miss storm but was just unique keys every request.

logging the key as the primary artifact, not the input values, changes the whole story. did you build anything to automatically detect 'identical keys firing rapidly' or is it still a manual log scan?

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

Yeah right now itโ€™s still manual.

The debugger tracks execution count and args fingerprint, so I can see when the same call repeats, but itโ€™s not doing any time-based detection yet. I just look at the logs and compare manually.

Detecting โ€œsame key firing rapidlyโ€ automatically would actually be pretty useful though, especially for separating real misses from concurrent renders or ISR noise without staring at timestamps.

Thread Thread
 
mudassirworks profile image
Mudassir Khan

the concurrent vs ISR distinction is actually solvable with a pretty simple heuristic โ€” if the same key fires within a 50ms window it's almost certainly concurrent render noise. real misses spread at least a few hundred ms apart. we added a time delta between same key calls to our tracing and it cut the false positive alert rate a lot. learned this the hard way after a bunch of ISR reruns looked identical to real misses.

does args fingerprint capture dynamic route segments, or does it normalize those out?

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

Right now it just fingerprints raw args, so dynamic segments are included as-is, not normalized.

Your time-delta heuristic is interesting though, feels like the next layer on top of what I have. Havenโ€™t implemented that yet.

Thread Thread
 
mudassirworks profile image
Mudassir Khan

raw args is actually the more useful default once you're aware of it. you want /product/123 and /product/456 treated as different until you've confirmed the underlying query is truly identical. normalizing up front hides useful signal.

the time delta is roughly 3 to 4 lines: if Date.now() - lastFireTime[key] is under 50ms, flag as concurrent noise rather than a real miss. where are you on implementing dedup for the fingerprint itself?

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

Yeah, keeping raw args as-is feels like the safer default for me too. Normalizing early just hides too much useful signal.

The time delta idea is nice. 50ms cutoff for concurrent noise sounds reasonable. I might try adding that in the next version of the debugger.

Right now fingerprint dedup is still basic. It just logs the fingerprint per call and I manually check against timestamps when things look off. Havenโ€™t added any automatic "same key in short window" detection yet.

Thread Thread
 
mudassirworks profile image
Mudassir Khan

manual timestamp check is fine until you have concurrent renders on different RSC paths hitting the same cache key โ€” that's when manual stops being tractable.

"same key in short window" detection is literally 3 lines: Map of key โ†’ last seen timestamp, compare on each log call, flag under 50ms. no storage overhead.

planning to surface that as a separate "concurrent noise" category in the UI, or just an annotation on the existing log entries?

Collapse
 
syedahmershah profile image
Syed Ahmer Shah

This is incredibly helpful. Next.js 16โ€™s new use cache model is an amazing architectural step forward, but the "black box" development experience where it silently fails due to simple function wrapping or missing a second argument in revalidateTag has been a massive headache. Catching dynamic holes from tight cacheLife windows before hitting production is a lifesaver for avoiding accidental request-time compilation drops. ๐Ÿ‘

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Exactly. The silent failure on wrapper placement is what got me too. You stare at the code, the directive is right there, and nothing tells you itโ€™s doing nothing. The dynamic hole issue is especially sneaky because the page still works, it just quietly becomes fully dynamic and you only notice when performance drops.
Catching those early, especially with things like tight cacheLife windows or revalidateTag edge cases, saves a lot of production surprises. Really glad itโ€™s been helpful!

Collapse
 
webdeveloperhyper profile image
Web Developer Hyper

Wow! The tool looks helpful for devs struggling with Next.js 16 caching. Thank you for sharing. ๐Ÿ‘ Next.js has many types of caching that are hard for me to understand ๐Ÿ˜…

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thank you! And honestly, you're not alone. The caching model in Next.js 16 is genuinely a lot to wrap your head around at first. If you're finding it confusing, the practical migration guide linked at the bottom might help. It walks through what 'use cache', cacheLife, and cacheTag are actually doing before you start using the toolkit. Hope it helps!

Collapse
 
webdeveloperhyper profile image
Web Developer Hyper

Thank you! ๐Ÿ˜„ Iโ€™ll refer to your documents carefully when using Next.js caching. Your documents seem to be detailed and comprehensive. ๐Ÿ‘

Collapse
 
99tools profile image
99Tools

This is actually super useful. The biggest issue with Next.js 16 caching right now is the lack of visibility during development, and your debugger solves that really well.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Exactly this. That's what pushed me to build it in the first place. You add the directive, assume it's working, and only find out it isn't when something breaks in prod. Having it surface misses and dynamic holes in real time during dev just saves so much guesswork. Glad it's useful!

Collapse
 
coridev profile image
Cor E

nice!

Collapse
 
leob profile image
leob

Cool, nice work!

Collapse
 
glasswing profile image
Glasswing

Nice!

Collapse
 
mudassirworks profile image
Mudassir Khan

the 'keys identical and still firing rapidly' heuristic is the one i was missing. we were comparing inputs and timestamps but not the keys, so the dedup story was invisible.

one extension: we now log the stale indicator alongside the key. identical keys with stale=true plus rapid timestamps usually means the revalidation tag fired but the cache didn't invalidate cleanly. different failure mode, same surface signal.

does your debugger surface the stale status or is that still a manual fetch?