ISR & the Redis cache
How a knext app's ISR/data cache reaches Redis — where the wiring lives, what the runtime guarantees, and how to upgrade an app scaffolded before the cache wiring landed.
knext's ISR / data cache is Redis. Every pod shares one keyspace, so a page rendered (or a tag invalidated) by one pod is seen by all of them — which is what makes ISR coherent under scale-to-zero, where pods appear and disappear constantly.
Where the wiring lives
On the default build (build: 'turbopack' or 'webpack'), Next reads the cacheHandler
option in next.config.ts. knext create scaffolds it, pointing at a cache-handler.js file in
your project, together with cacheMaxMemorySize: 0 so the per-pod memory cache never diverges
from Redis:
const nextConfig: NextConfig = {
cacheHandler: new URL('./cache-handler.js', import.meta.url).pathname,
cacheMaxMemorySize: 0,
};Keep the handler file inside your project so next build copies it into the standalone output.
On the vinext build (build: 'vinext'), the app builds with vinext (Vite/rolldown). vinext
does not read the cacheHandler option in next.config.ts — that option belongs to Next's
webpack/turbopack pipeline, which this build never runs. The line is kept in scaffolded apps for
Next-aware tooling, but it is inert at runtime.
The live registration on vinext is in vite.config.ts, on the vinext() plugin itself:
import vinext from 'vinext';
export default defineConfig({
plugins: [
vinext({
cache: {
data: {
// `-node` on `runtime: 'node'`, `-bun` on the default Bun runtime.
adapter: '@getknext/core/internal/vinext-cache-adapter-bun',
},
},
}),
// …nitro(…)
],
});The adapter is picked per runtime so the Redis client is resolved at build time: the -node
adapter imports ioredis directly, so the build ships it in the image, and the -bun adapter uses
Bun's built-in Redis client. If REDIS_URL is set but the client cannot be loaded, the app logs one
Redis client unavailable error at startup and serves from memory — ISR is then not shared between
pods and is lost on scale-to-zero. All three subpaths work with vinext 1.0.1 and 1.1.0. The generic
…/vinext-cache-adapter subpath still works for apps scaffolded earlier, but it cannot be traced by the build, so switch it to the one for your runtime.
At build time, vinext generates a registration module from this option and runs it in every server entry, so the Redis-backed handler is registered before any route executes. Page-level ISR (HIT / STALE serving and in-process background regeneration) then flows through that handler.
Deleting this block does not fail the build. The app still serves 200s — with a per-pod
in-memory cache instead: every pod renders independently, nothing survives scale-to-zero, and
the provisioned Redis stays empty. If you see x-nextjs-cache: MISS on every request and an empty
Redis, this block is the first thing to check.
What the runtime guarantees
- First request after a cold cache is a
MISSand writes the rendered page to Redis. - Within the revalidate window requests are served
HITfrom Redis — including the first request after the app scales up from zero, for pages generated at request time as well as prerendered ones. Each entry carries its own revalidate window, so a freshly started pod does not need to have rendered the page to know it is still fresh. That applies only to entries written by the same build; after a redeploy, entries from the previous build are revalidated as usual. - Past the window requests are served
STALEfrom Redis while a background render replaces the entry — stale-while-revalidate, not a blocking re-render. - The Redis entry's TTL is deliberately longer than the revalidate window (it is the route's expire time when one is declared, and a floor of one hour otherwise). If the entry expired exactly at the revalidate window there would be nothing left to serve stale.
- When
REDIS_URLis unset (local dev), the handler falls back to in-memory caching, sovinext devand local builds work without a Redis. - The Redis client depends on the runtime in your deploy config. On
nodethe handler usesioredis, which the build copies into the image. Onbunit uses Bun's built-in Redis client.knext build,knext deployandknext previewpick the right one for you. - When
REDIS_URLis set but the Redis client cannot be loaded in the pod, the handler still serves from memory, but it logs one error at startup that starts with[CacheHandler] Redis client unavailable. If you see it, cache entries are not shared between pods and do not survive scale-to-zero. Check that you built the image withknext deployorknext buildfor the same runtime you deploy.
On the standalone build's Node runtime, the Redis-backed handler can silently fall back to a
per-pod in-memory cache. This affects runtime: 'node' only — the Bun runtime (the default) and
the vinext compiled executable are not affected. The app still serves 200s, but pages render
independently per pod, nothing survives scale-to-zero, and the provisioned Redis stays empty — the
same symptom as deleting the cacheHandler block above. If you need a shared cache today, use the
Bun runtime. This is fixed in the next patch release; check the release notes for your version.
The connection details come from the environment — REDIS_URL and REDIS_KEY_PREFIX are injected
into your pods from the cache block of your deploy config (see
the operator reference); nothing about Redis is hardcoded in your app.
'use cache' is per-process today
The Redis cache above covers ISR and the response cache. It does not back Next's 'use cache'
directive (Cache Components). Today, on knext:
- A server-side
'use cache'entry is per-process: each pod keeps its own copy in memory. It is not shared across pods and is lost on scale-to-zero, so a fresh pod recomputes it. - The scaffolded
next.config.tssetscacheMaxMemorySize: 0and registers nocacheHandlers. With that,'use cache'keeps nothing at all, so the directive is effectively a no-op. Your code still runs and returns correct results; it just is not cached. Keep that setting: raising it lets each pod's in-memory cache drift from the shared Redis cache. 'use cache: remote'has no shared store behind it either, so it behaves the same way.- Tag invalidation with
revalidateTagstill reaches ISR pages, but it cannot invalidate a'use cache'entry held by another pod.
Plan around that: treat 'use cache' as a per-pod speed-up, not as a shared cache, and keep
anything that must be shared (or must survive scale-to-zero) in ISR or your own data store.
knext doctor warns when your source uses 'use cache'. It names the first file it finds and
tells you which case applies: a no-op when your next.config sets cacheMaxMemorySize: 0, or
per-process otherwise. Run it from your app directory, the one with next.config. It skips
dependencies, build output, the public folder and symlinks. In a directory with a very large number of files the
scan stops early and says so instead of guessing. The warning never fails the check.
A 'use cache' store shared across pods is planned for a future major version. Until it ships, the
behaviour above is what you get.
The Cache-Control header clients see
Next.js marks cacheable pages with directives meant for a shared cache in front of the server, such
as s-maxage=2, stale-while-revalidate=31535998 for an ISR page or s-maxage=31536000 for a
static one. knext serves your app directly, with no shared cache in between, so every build
rewrites those responses to public, max-age=0, must-revalidate: the standalone server
(build: 'turbopack' or 'webpack', on Node or Bun — the default), the vinext compiled
executable, and vinext on Node (build: 'vinext' with runtime: 'node'). That's the header a deployed Next.js app
returns to browsers.
The first request for a fallback: true page gets the same header rather than private, no-store.
Hashed static assets (immutable) and non-GET/HEAD responses are never changed.
With the vinext build (the compiled executable, or vinext on Node), two layers produce this header:
- vinext's own deploy mode covers the responses vinext builds itself: ISR and static pages,
/_next/datarequests, and the first request for afallback: truepage. The server turns it on by settingVINEXT_NEXT_DEPLOY_CACHE_CONTROL=1, unless you've set that variable yourself. - knext's rewrite covers
s-maxageheaders your own code sets, for example in a route handler or on a redirect.
If you put your own CDN or shared cache in front of knext and want it to see the s-maxage values,
set KNEXT_CACHE_CONTROL_NORMALIZE=0 in the app's environment. That one switch turns both layers
off, and the server sends Next.js's original headers. To turn off only vinext's layer, set
VINEXT_NEXT_DEPLOY_CACHE_CONTROL=0.
On vinext on Node, both layers live in the app's knext-node-entry.mjs. An app created before
this behaviour shipped has an older copy of that file and still serves Next.js's original headers;
replace it with the one from a freshly created app (knext create).
Upgrading a vinext app scaffolded before the cache wiring
Vinext apps created by earlier versions of knext create wired the cache handler only through
next.config.ts's cacheHandler — which the vinext build ignores, so their ISR never reached
Redis. To fix an existing app, add the cache block to the vinext() call in your
vite.config.ts exactly as shown above, and make sure @getknext/core is in your
devDependencies. No other change is needed: the handler, the environment contract, and the
operator side were already in place.
To verify after deploying: request an ISR route twice — the second response should carry
x-nextjs-cache: HIT — and redis-cli KEYS '<your-prefix>:cache:*' should show the page's
entries, with TTL greater than the route's revalidate window.
Warm floors by workload class
Which apps should keep pods warm, which should stay at zero, and what the measurements actually support — a decision guide for minScale, scheduled warm windows and image prewarming.
Cold starts & image caching
Keep your application image resident on every node so a pod waking from zero never waits on an image pull — and the node cost of doing it.