Horizontal Scaling with a Custom Redis cacheHandler on a Self-Hosted Next.js Setup — Is This Approach Complete? #91824
Replies: 1 comment
-
|
It looks like you've done the heavy lifting here. Disabling Your approach is mostly complete, but there are two "gotchas" that might trip you up in a production environment: 1. Tag Invalidation Performance 2. Image Optimization const nextConfig: NextConfig = {
cacheHandler: require.resolve("./cache-handler.js"),
images: {
customCacheHandler: true,
},
// ...
};3. The "First Request" Cold Miss Otherwise, using |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Horizontal Scaling with a Custom Redis cacheHandler on a Self-Hosted Next.js Setup — Is This Approach Complete?
Context
I'm planning to self-host multiple Next.js 16 instances (Docker containers behind a load balancer) and will need a shared cache layer so that:
revalidateTag/revalidatePathfrom any instance invalidates across all instances.next buildare available immediately (no cold-miss on first request).I'm using the singular
cacheHandleroption innext.config.ts— the one documented for ISR and route handler responses — notcacheHandlers(plural) /use cachedirectives. My cache backend is Redis via the officialredispackage (createClient).My Setup
1.
next.config.ts— Registering the handlerKey decisions:
cacheMaxMemorySize: 0— Disables Next.js's built-in in-memory LRU. Without this, each instance keeps its own stale copy, defeating shared cache. Note: my cache handler has its own small LRU (500 entries) purely as a fallback when Redis is down — this is independent from Next.js's built-in cache.generateBuildIdfromGIT_HASH— All containers share the same build, and I use this to detect new deployments in the cache handler and flush stale entries.2.
cache-handler.js— Full ImplementationClick to expand full cache-handler.js (~230 lines)
Design notes:
v8.serialize— HandlesBuffer(for.rscdata),Map(forsegmentData), and other non-JSON types natively, whichJSON.stringifycannot.client.isReady === false. This is separate from Next.js's built-in in-memory cache (disabled viacacheMaxMemorySize: 0). It's purely a resilience layer so the app doesn't crash if Redis goes down.revalidateTaglooks up all keys for a tag and deletes them atomically.max(revalidate * 2, revalidate + 60)gives stale-while-revalidate room to work.BUILD_IDwith stored value. If different, flushes allCACHE_PREFIX*keys via SCAN.3.
instrumentation.ts+instrumentation.node.ts— Pre-populating Redis from Build OutputThis is the piece I'm most uncertain about.
The problem:
next buildwrites pre-rendered pages directly to.next/server/app/on disk and never callsset()on the cache handler. At runtime, Next.js callsget()through the cache handler. Result: every pre-rendered page is a cache miss on first request → unnecessary re-render.My solution: At server startup (via the
register()function ininstrumentation.ts), I read the build output and manually populate Redis:My Open Questions
1. Is the build-output pre-population approach correct?
I'm reconstructing the
IncrementalCacheEntryshape manually from the.html,.rsc,.meta, and.segments/files. This works today on Next.js 16, but:kind,html,rscData,headers,status,postponed, andsegmentData. Are there others?2. What about
FETCHcache entries?My instrumentation only handles
APP_PAGEentries. Butnext buildalso generates fetch cache entries in.next/cache/fetch-cache/.3. Race condition during rolling deployments
When deploying N containers:
BUILD_ID→ flushes Redis → populates from build output.BUILD_IDalready in Redis → skips flush.But what if Container 2 starts while Container 1 is still populating? Could it serve requests with a partially-populated cache? I handle this with a
ready()promise + Redis lock, but is there a better pattern?Related: the self-hosting docs mention
deploymentIdfor version skew protection. DoesdeploymentIdinteract withcacheHandlerin any way? Or is it purely for client-side asset/navigation consistency?4.
revalidateTagpropagation across instancesMy
revalidateTagdeletes keys from Redis, which works because all instances read from the same Redis. But:get()and re-renders. Is that the expected behavior?cacheMaxMemorySize: 0disables Next.js's built-in LRU, are there any other in-process caches that could serve stale data?5. Tag sets growing indefinitely in Redis
My tag index (
tag:* → Set of cache keys) doesn't have an EXPIRE. Cache entries themselves expire via TTL, but the tag sets accumulate stale pointers over time.revalidateTagcleans them up, but tags that are never explicitly revalidated grow indefinitely.Is adding an EXPIRE on tag sets safe? Or would that risk losing the index before a revalidation happens? What's the recommended cleanup strategy?
6. Dynamic routes and
generateStaticParamsFor routes like
/blog/[slug]withgenerateStaticParams:actual-param-value.html(e.g.,my-post.html)?[...slug]) need special handling?7. Streaming interaction with cache handler
The self-hosting docs mention disabling proxy buffering for streaming. Does streaming interact with the cache handler? Are streamed responses cached differently, or does the handler only see the final complete response?
What I'm Hoping For
cacheHandler+ instrumentation pre-population is a viable pattern for horizontal scaling.I understand
cacheHandlers(plural) withuse cacheis the newer direction, but I'm on a production app using the singularcacheHandlerand want to make sure this approach is solid before considering migration.Environment
output: "standalone"redis/createClient)instrumentation.tsfor cache warm-up at server startupThanks for any feedback!
Beta Was this translation helpful? Give feedback.
All reactions