Next.js ISR Revalidation Race Condition Wiped Our Client Homepage cover image
Back to Blog
TechnologyPublished 17 July 2026· Updated 22 August 2026· 6 min read

Next.js ISR Revalidation Race Condition Wiped Our Client Homepage

A silent race condition in Next.js 15.0.2 served 14-day-old pricing during a flash sale. Here is the fix we kept.

The Incident: Stale Pricing on the Client Homepage During a Flash Sale

On the morning of March 14, 2026, our client's Next.js 15.0.2 e-commerce homepage began serving product pricing that was 14 days old during a high-traffic flash sale. Within 90 minutes, 1.02 million unique users were exposed to incorrect prices, resulting in 14,000 refunded orders and $47,000 in direct revenue loss. Twelve enterprise clients threatened to cancel their contracts. The root cause was a race condition in Next.js 15.0.0 through 15.0.3 that silently dropped 34% of on-demand revalidation requests under concurrent load.

We were running three Next.js instances behind a load balancer on Vercel. The homepage used ISR with revalidatePath('/') triggered by a webhook from the pricing service. When the flash sale started, the webhook fired hundreds of revalidation calls per second. Instead of refreshing the cache, the race condition in PR #71234 caused most of those calls to be silently dropped. The unsynchronized in-memory array used to batch revalidation calls was overwriting pending entries instead of appending them.

The first alert came from our synthetic monitor at 09:14 IST. It flagged that the homepage was returning a last-modified header from February 28. By 09:20, the support queue was flooding with screenshots of mismatched prices. By 09:45, we had confirmed the issue was not with our deployment pipeline but with the Next.js runtime itself.

What We Tried First (And Why It Failed)

Our initial response was to manually trigger revalidation via the Vercel dashboard and our internal /api/revalidate endpoint. We called revalidatePath('/') and revalidateTag('homepage') repeatedly, but the stale content persisted. We also attempted to force a rebuild by hitting the deployment webhook, but ISR caches on each instance were not being invalidated consistently.

The problem was not with our deployment pipeline but with the Next.js runtime itself. The unsynchronized in-memory array used by PR #71234 to batch revalidation calls was overwriting pending entries instead of appending them, causing revalidations to be silently dropped. Each of our three instances had its own local cache, and the race condition meant that even when one instance did revalidate, the other two kept serving stale data.

We tried restarting individual instances, but that only cleared the cache on the restarted instance. The load balancer would route some users to the still-stale instances. We also tried increasing the revalidation interval to revalidate = 30, but that did not help because the bug only affected on-demand revalidation, not time-based revalidation.

At 10:30 IST, we temporarily switched the homepage to dynamic rendering by removing the revalidate export. This ensured every request fetched fresh data, but it increased server load and latency. We kept this as a temporary mitigation while we worked on the real fix.

The Working Fix: Upgrade and Distributed Locking

The fix had two parts. First, we upgraded all Next.js deployments from 15.0.2 to 15.0.4, which included the fix from PR #71892 that replaced the in-memory array with a synchronized Map. Second, as a safety net for high-concurrency scenarios, we implemented a distributed lock using Upstash Redis around our revalidation calls.

import { Redis } from '@upstash/redis';
import { revalidatePath, revalidateTag } from 'next/cache';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL,
  token: process.env.UPSTASH_REDIS_TOKEN,
});

export async function safeRevalidatePath(path) {
  const lockKey = `revalidate-lock:${path}`;
  const lockId = crypto.randomUUID();
  const acquired = await redis.set(lockKey, lockId, { nx: true, ex: 10 });
  if (!acquired) {
    console.warn(`Revalidation lock not acquired for ${path}, skipping`);
    return;
  }
  try {
    await revalidatePath(path);
  } finally {
    await redis.del(lockKey);
  }
}

We also enabled ISR revalidation audit logs in the Vercel dashboard and added synthetic monitoring via Checkly to fetch product pages every 60 seconds, alerting if pricing data was older than 5 minutes.

The upgrade to 15.0.4 was straightforward. We updated package.json and ran npm install next@15.0.4. The build completed without errors, and the new synchronized Map logic immediately resolved the race condition. After the upgrade, our revalidation success rate jumped from 66% to 100% under the same load.

The distributed lock wrapper became our standard pattern for all on-demand revalidation calls. Even though the bug was fixed in 15.0.4, we kept the lock as a safety net for any future regressions and for environments where we cannot guarantee all instances are on the latest version.

Pitfalls We Would Warn an Intern About

  • Do not assume revalidatePath is synchronous or guaranteed to complete immediately. It schedules revalidation, but the cache may remain stale until the background process finishes.
  • Never call revalidatePath or revalidateTag in a loop or under high concurrency without a distributed lock. The race condition in 15.0.x will silently drop requests.
  • On-demand ISR does not work reliably in autoscaling environments unless you have a shared cache or a mechanism to broadcast invalidation to all instances. Each container keeps its own ISR cache in memory and on the filesystem.
  • Time-based revalidation (export const revalidate = 60) was not affected by the bug. If you cannot upgrade immediately, consider falling back to time-based intervals as a temporary mitigation.
  • Always test revalidation under load. A single request will not expose the race condition; you need at least 50 concurrent requests hitting the same path simultaneously.

We also learned the hard way that the Vercel dashboard does not show revalidation failures by default. You have to enable audit logs manually. Without those logs, we would have had no visibility into how many revalidation calls were being dropped.

What We Would Do Differently Next Time

  • We would pin Next.js versions in production and subscribe to the Next.js release notes RSS feed to catch regressions before they ship to production.
  • We would implement a shared Redis-backed cache handler from the start using @neshca/cache-handler instead of relying on local instance caches. This eliminates the multi-instance invalidation problem entirely.
  • We would add revalidation success rate as a core SLO, monitored via synthetic checks, rather than discovering the issue through customer complaints.
  • We would use revalidateTag with fetch calls that include next.tags so that tag-based invalidation is more predictable and easier to trace.
  • For critical pages like the homepage, we would consider dynamic rendering or passing fresh data via searchParams on redirect after a mutation, rather than depending solely on ISR catch-up.

The incident also pushed us to adopt a more defensive stance toward ISR. We now treat on-demand revalidation as a best-effort mechanism, not a guarantee. For pages where stale data has real business impact, we either use dynamic rendering or implement client-side refetching as a fallback.

References

Enjoyed this article?

Back to Blog