IAnswerable

Incremental Static Regeneration on Your Own Server

13 Jan 2026

Incremental Static Regeneration works out of the box on Vercel because Vercel’s infrastructure handles the on-demand cache for you. Self-hosted, ISR still works — but the default cache is a folder on one machine’s disk, and that assumption breaks the moment you run more than one instance.

What ISR actually caches, and where

When a page uses revalidate, Next.js writes the rendered HTML to a cache after the first request, and serves that cached copy until the revalidate window expires — then regenerates it in the background on the next request.

Self-hosted with the default file system cache, that cache lives at .next/cache on whatever container served the request. That’s fine with exactly one instance. It breaks in two specific ways the moment you scale beyond that:

flowchart LR
    U1[User A] --> LB[Load balancer]
    U2[User B] --> LB
    LB --> I1["Instance 1\n(own disk cache)"]
    LB --> I2["Instance 2\n(own disk cache)"]
    I1 -. no shared state .- I2
  
  1. Multiple containers behind a load balancer. Each instance has its own cache. User A hits instance 1 and triggers a regeneration; user B hits instance 2 five seconds later and triggers the same regeneration again, because instance 2’s cache doesn’t know about instance 1’s work. You lose the whole point of ISR — avoiding redundant renders — and pay the render cost on every instance independently.

  2. Every redeploy wipes it. A new container starts with an empty .next/cache. Every page gets rendered fresh on first request after every deploy, all at once, which is your worst-case load pattern happening right when your app just restarted.

The fix: a shared cache handler

Next.js supports a custom cache handler so you can point ISR at Redis, or any shared store, instead of local disk:

// cache-handler.js
const { createClient } = require('redis')

const client = createClient({ url: process.env.REDIS_URL })
client.connect()

module.exports = class RedisCacheHandler {
  async get(key) {
    const data = await client.get(key)
    return data ? JSON.parse(data) : null
  }

  async set(key, data, ctx) {
    await client.set(key, JSON.stringify(data), {
      EX: ctx?.revalidate || 60,
    })
  }

  async revalidateTag(tag) {
    // implement tag-based invalidation if you use revalidateTag()
  }
}
// next.config.js
module.exports = {
  cacheHandler: require.resolve('./cache-handler.js'),
  cacheMaxMemorySize: 0, // disable default in-memory cache when using a custom handler
}

Now every container reads and writes the same cache. One regeneration serves all instances, and a redeploy doesn’t wipe a warm cache — Redis survives the container restart even if the app doesn’t.

If you’re running a single instance

Don’t add Redis you don’t need. One container, no load balancer fanning out to multiple replicas — the default file system cache works fine. The failure mode above only shows up with horizontal scaling. Adding a Redis dependency for a single-box deployment is solving a problem you don’t have yet.

The revalidate-storm gotcha

Even with a shared cache, be careful with very short revalidate windows (under 10 seconds) on expensive pages. Every regeneration re-runs your data fetching and render logic. A short window on a page with a slow underlying query turns into a steady background load on your database that scales with traffic, not with how often the data actually changes. Match the window to how often the data genuinely updates, not to how fresh you’d like it to feel.

The takeaway

Single instance: default ISR works as documented. Multiple instances: you need a shared cache handler (Redis is the common choice) or you’ll get redundant regenerations and a cold cache on every deploy. Check your instance count before you debug “ISR isn’t working” — it’s usually working per-instance, just not shared.