A packed canvas backpack in a vehicle, representing a cache: a store of items kept close and ready, but opaque until you look inside

Next.js Hides Its Cache, Redis Doesn’t: The Observability Gap

When a cache starts underperforming, diagnosing it is a sequence of questions rather than a single number. Is the hit rate below what the access pattern predicts? If it is, is the cache evicting too aggressively, or are entries expiring before they should? Is it undersized, or is the eviction policy wrong for how the application reads? What is in there right now, how much memory is it using, and how long does each entry have left?

Redis answers every one of those, and its documentation walks through them in order as a diagnostic procedure. Self-hosted Next.js answers a narrow version of the first question, one response at a time, and leaves everything after it to whoever is running the server. Neither cache is slower than the other for it. A cache can be fast and hard to inspect at the same time, and the gap between these two is entirely in the second property.

Rundown

  • Redis exposes keyspace_hitskeyspace_missesevicted_keysexpired_keys, and used_memory_dataset through INFO, and its documentation chains them into a procedure for finding out why a hit rate is low.
  • The eviction policy and memory ceiling are changeable at runtime with CONFIG SET, without a restart or a redeploy.
  • Next.js exposes an x-nextjs-cache header per response (HITSTALEMISSREVALIDATED) for the Full Route Cache, and no aggregate at all. Rates, eviction counts, and cache size are left to the operator to derive.
  • A request for programmatic access to cache state has been open on the Next.js repository since September 2023, with 46 upvotes and no maintainer response.
  • The header stops at the Full Route Cache. Data behind fetch and use cache has no equivalent, logging.fetches is development-only, and production cache logging sits behind a NEXT_PRIVATE_ environment variable.
  • A custom cacheHandler closes the gap, because every lookup runs through code you wrote. The metrics come from replacing the cache, not from the framework.
  • Vercel’s Runtime Cache dashboard reports hit rate, reads, writes, revalidations, cache size, and eviction activity: the same measurements Redis prints, supplied by the host rather than the framework.

What Redis exposes

INFO stats returns keyspace_hits and keyspace_misses as running counters, which is enough to compute a hit rate:

redis-cli INFO stats | tr -d '\r' | awk -F: \
  '/^keyspace_hits:/{h=$2} /^keyspace_misses:/{m=$2} \
   END {printf "hits=%d misses=%d rate=%.2f%%\n", h, m, (h+m) ? h/(h+m)*100 : 0}'

One number on its own would not settle anything. What makes it worth having is that the follow-up question also has an answer. Redis’s eviction reference lays out the sequence. A rate below what the access pattern predicts sends you to evicted_keys, since heavy eviction points at a policy discarding entries the application still wanted. Low evictions on an application that sets TTLs sends you to expired_keys instead, where a large count means the expiry window is too short or attached to the wrong keys. From there, used_memory_dataset gives the memory the cached data occupies, current_eviction_exceeded_time gives how long the cache has been over its ceiling, and commandstats reports which commands the memory limit is turning away.

Each answer narrows the next question, and each one is a field in the output of a command that was already running. Redis’s observability guide covers the same counters alongside heavier instruments: SLOWLOG for commands that exceeded a configured execution time, the LATENCY framework with dedicated hooks for the eviction and expiry cycles, and redis-cli --bigkeys--memkeys, and --hotkeys for finding which keys dominate memory or traffic. It is candid about the cost of the bluntest one, noting that MONITOR streams back every command and has been observed to halve throughput while running.

On the server behind this site, that command currently returns hits=294852 misses=382808 rate=43.51%. Three caveats sit inside that figure. The counters are cumulative since the server started or since the last CONFIG RESETSTAT. They cover every key in the instance rather than one application’s cache, so this is instance-wide rather than a page-cache hit rate. And EXISTSreturning false for an absent key counts as a keyspace miss, so an application that checks for keys before writing them inflates its own miss count.

All three are knowable, and that is what the number is for. A rate of 43.51% is where the sequence starts rather than a verdict, and working through it would establish which explanation applies. A framework-managed cache sitting at the same rate is indistinguishable from one at 95%, and no procedure begins.

What self-hosted Next.js exposes

Next.js does expose per-response cache status. The ISR guide documents an x-nextjs-cache response header carrying HITSTALEMISS, or REVALIDATED, and presents it as the way to observe cache behavior. It covers the Full Route Cache, meaning statically generated and incrementally regenerated pages, and it works self-hosted.

What it does not do is aggregate. The header answers whether one response came from cache; it does not answer what the hit rate has been over the last hour, how many entries were evicted, or how large the cache has grown. Turning per-response tags into a rate means logging the header and counting it yourself, which on NGINX means capturing it as an upstream header:

log_format cachelog '$request_uri $upstream_http_x_nextjs_cache';

That works, and it is worth setting up. It is also the difference under discussion: Redis reports the aggregate as a first-class counter, and Next.js reports the individual events and leaves the arithmetic to the operator. The header also stops at the Full Route Cache. Data fetched through fetch or use cache sits in a separate layer with no equivalent header.

Framework-level logging does not close that gap either. Next.js’s logging configuration reference describes itself as configuring terminal output when running in development mode. The logging.fetches option that prints cache status per fetch has been stable since v14.0.0 and remains development-only. The built-in OpenTelemetry instrumentation emits spans for fetches, renders, and route handlers, which gives per-request timing rather than cache counters.

One production-side option does exist. Setting NEXT_PRIVATE_DEBUG_CACHE=1 makes the server console log ISR cache hits and misses, and the ISR guide documents it under verifying correct production behavior. The name is the caveat: a variable prefixed NEXT_PRIVATE_ is not a stable public API, and it produces log lines rather than counters.

discussion opened in September 2023 requests the piece that is missing: a way to read, in code, whether a response came from cache, exposed on the response object rather than only as a header on the way out. It has 46 upvotes, six participants, replies running into December 2024, and no response from a Next.js maintainer in nearly three years.

The participants disagree with each other, which matters for reading the thread accurately. One commenter argues twice that cache hits and misses already appear in production logs and that surfacing them is the host provider’s job, noting that it “already works by default with Vercel.” He is right about the header, and his narrower point is the useful one: the request is about reacting to cache state programmatically, not about reading logs. The gap is an API gap.

Next.js 16’s use cachecacheLife, and cacheTag sit on the same underlying layer. They changed how caching is declared without adding a way for application code to query it.

Laid alongside each other, the difference is less about any single metric than about how many of the questions have an answer at all.

QuestionRedisSelf-hosted Next.js
What share of lookups hit?keyspace_hitskeyspace_missesDerive it from logged x-nextjs-cache values
Is it evicting entries?evicted_keysNo framework API
Are entries expiring too early?expired_keysNo framework API
How much memory is the data using?used_memory_datasetNo framework API
What is cached right now?SCANTTL per key, --bigkeysNo framework API
Change the eviction policyCONFIG SET maxmemory-policy, liveOnly by writing a custom cacheHandler
Change the size limitCONFIG SET maxmemory, livecacheMaxMemorySize, then restart
Drop one entryUNLINKrevalidatePath or revalidateTag, current instance only
Share one cache across processesBuilt inRequires a custom cacheHandler

The right column is not a list of things Next.js does badly. It is a list of questions that a cache living inside a framework has no reason to answer, because the framework is not expecting anyone to ask.

Writing your own cache handler

Next.js documents an answer for self-hosting. The self-hosting guide covers a custom cacheHandler: a class implementing getsetrevalidateTag, and resetRequestCache, registered in next.config.js and usually paired with cacheMaxMemorySize: 0 to disable the default in-memory cache. Next.js 16 extends this through cacheHandlers for use cache and use cache: remote.

Implement that class and you can count hits and misses, because every lookup passes through your code. The counters exist once the cache is infrastructure you run. What the framework supplies is an interface to replace it through, not a set of metrics.

The documentation is candid about how much that is. The default cache holds 50MB in memory plus on-disk storage per instance and is not shared across them, so each pod carries its own copy and no aggregate view exists without building one. Calling revalidateTag() on one instance invalidates only that instance, which is why a multi-instance handler also needs a refreshTags() method to sync tag state from shared storage before each request. And the documented handler example is offered as a starting point to be extended with durable storage, eviction policies, error handling, and distributed tag coordination.

That is a list of things a cache has to do, not a list of things a cache reports. Next.js’s own worked example of where to put it is Redis.

What Vercel provides

Vercel documents the metrics its own platform reports. Under Runtime Cache in Observability, a deployed project gets cache hit rate, cache reads, cache writes, and on-demand revalidations, plus cache size and eviction activity, with per-request cache usage available in Logs. The cache runs on an LRU eviction policy that Vercel operates.

That list maps almost exactly onto INFO stats. Hit rate against keyspace_hits and keyspace_misses, eviction activity against evicted_keys, cache size against memory usage. These are the standard measurements for any cache. The difference is not which numbers matter, but who is expected to produce them.

Deployed on Vercel, the platform does. Self-hosted, the framework reports individual events and nobody aggregates them until you build a cacheHandler that keeps count, at which point you own the cache and can measure it the way you would measure Redis.

Owning the cache

Metrics are the visible half of this. The other half is how many of the cache’s controls can be reached at all.

A cache running as its own service can be changed while it runs. CONFIG SET maxmemory resizes it without a restart, CONFIG SET maxmemory-policy switches between ten documented eviction strategies, and CONFIG SET maxmemory-samplestunes how closely the approximated LRU tracks true LRU. Individual keys can be inspected, given a TTL, or deleted, and one instance serves every process on the box. None of those commands change when the application framework changes, so what an operator learns about running it stays true across versions and across projects.

A framework-managed cache offers the operations the framework exposes in the version currently installed. Next.js’s own documentation shows how much that moves: the ISR guide points at incrementalCacheHandlerPath, the self-hosting guide configures cacheHandler, and use cache backends are configured through cacheHandlers. Each shift is reasonable on its own, and together they mean the operational knowledge has a shelf life.

Ownership is not free. A separate cache service is another process to run, secure, monitor, and restart, and it introduces a network hop and a new failure mode. An application that needs tag invalidation coordinated across regions gets real value from a platform that already does it, and rebuilding that is expensive.

The trade is worth making when caching needs are simple enough that running the service costs less than the visibility is worth. A hit rate of 43.51% is a problem, but it is a problem with a number attached, a policy to change, and a command that will report whether the change worked. The same rate inside a framework-managed cache is not a problem anyone knows they have.

Related reading

The memory leak case study covers the same self-hosted-versus-managed gap from the memory side, and the full migration writeup sets out where this fits among the other reasons for moving off Next.js. The React SSR event loop piececovers a third case where framework-owned machinery costs something a self-hosted operator has to measure themselves.

On what a framework still buys, the case for vanilla JavaScript covers what the platform now handles natively, and what Node.js gained covers what the runtime absorbed since React launched.

RSS Feed Newsletter
Contact us

Latest Blog Posts