Back to blog
August 13, 2026Sergei Solod20 min read

Why Old Next.js Tabs Break After a Deployment: Stale HTML, Missing Chunks, and Version Skew

After a deployment, my production telemetry captured a failed first-party Next.js chunk. The log proved the script failed, not why. This article uses that incident to explain old tabs, stale HTML, missing /_next/static assets, version skew, retention, deploymentId, rollout order, monitoring, and controlled recovery.

Next.jsDeploymentVersion SkewWeb CachingFrontend ReliabilityStatic Assets

One of the most useful production errors I saw after a deployment looked almost boring:

Failed to load script:
/_next/static/chunks/9253.647385b4be0958e4.js

It appeared in the same stream as failures from analytics, advertising scripts, generic Script error. messages, and interrupted video playback. Most of that stream was noise. This one was different. The failed resource belonged to my own Next.js application. If the browser genuinely could not load it, part of the page could stop working.

What I did not know from that log was why the chunk failed. The browser could have hit a transient network problem. A proxy or CDN could have failed. The file could have been missing. Or an older page could have been asking for a chunk from the previous deployment after the server had already replaced that build.

That last failure mode is easy to underestimate because the deployment itself can be perfectly healthy. The new version may load correctly for every fresh visitor while a tab that has been open for hours quietly remains a client of the old version.

This article is about that compatibility gap: why old Next.js tabs can break after a deployment, how stale HTML and missing /_next/static assets create version skew, why aggressive cleanup makes the problem worse, and how I would design deployment, retention, monitoring, and recovery so that a successful release does not strand users who already had the application open.

The first lesson was not to call every script failure a deployment bug

The original error stream contained several completely different classes of failure. Third-party analytics and ad scripts can be blocked by content blockers, DNS filters, privacy features, regional filtering, antivirus software, or the user's network. A video play() promise can be interrupted by a later pause() without the application being broken. A generic cross-origin Script error. often provides too little information to diagnose anything.

A failed first-party Next.js chunk deserves a different priority. The useful boundary is not “JavaScript error versus no JavaScript error.” It is closer to this:

third-party resource failed
    -> usually telemetry or degraded optional functionality

first-party /_next/static/*.js failed
    -> application code may be unavailable

That distinction matters because a noisy reporter can hide the failures that actually correlate with broken pages. In my case, the important event was the request for /_next/static/chunks/9253.647385b4be0958e4.js. The log proved that a first-party script load failed. It did not prove that deployment skew caused it.

I keep that evidentiary line deliberately sharp. A plausible cause is not a confirmed cause.

An open browser tab is effectively a client from an older release

The mental model that changed the problem for me is simple: after a deployment, there may be more than one version of the application alive at the same time.

Suppose release A is live at 10:00. A user opens a page and receives HTML plus the JavaScript needed for that route. At 10:30, release B replaces it. New visitors receive release B. But the user's tab does not automatically become release B just because the server changed.

That tab may still contain:

  • the JavaScript runtime loaded from release A;
  • route and chunk references generated by release A;
  • prefetched navigation data from release A;
  • React state created under release A;
  • code-split modules from A that have already been downloaded;
  • references to modules from A that have not been downloaded yet.

The last item is where the failure becomes visible.

If every chunk the page will ever need was already in the browser cache, the user may continue without noticing anything. But modern Next.js applications split code. A route transition, a dynamic import, a modal, an editor, or some feature used later can require another JavaScript file. The old runtime then asks for an asset whose URL was valid for release A.

If the server still has that asset, everything may continue to work. If the deployment deleted it, the old client can receive a 404 even though release B itself is completely healthy.

Content-hashed chunks are designed to be cached for a long time

Next.js deliberately gives truly immutable assets long-lived caching semantics. Its current self-hosting documentation says immutable assets with SHA hashes in their filenames are served with a one-year policy such as:

Cache-Control: public, max-age=31536000, immutable

That policy is sensible because the URL changes when the content changes. A file named with a content-derived hash does not need to be revalidated on every request. If a later build produces different bytes, it should produce a different asset URL.

The important consequence is easy to miss: the old URL remains meaningful for as long as an old document or old runtime can still refer to it.

The browser's ability to cache a hashed asset for a year does not help if the browser never downloaded that particular asset before the deployment and the origin has already deleted it.

This is why “our static files are immutable” and “we can immediately delete static files from the previous release” are not equivalent statements. Immutability makes old assets safe to keep. It does not make old clients stop asking for them.

The current Next.js self-hosting guide explicitly describes missing JavaScript or CSS assets as one symptom of version skew during multi-server or rolling deployments. That is the same family of problem, even when the skew is between an old tab and a newly deployed origin rather than two servers active at the same moment.

There are several different ways the versions can split

“Cache problem” is too vague to be useful. I separate at least four mechanisms because they require different fixes.

1. An old tab requests an asset that was never loaded before the deployment

This is the classic long-lived-tab case. The document and runtime came from release A. Release B replaces the files on the server. Later, the user performs an action that triggers a lazy chunk from A. If A's asset was removed, the request fails.

2. Stale HTML points at chunks that no longer exist

A CDN, reverse proxy, service worker, browser cache, or static hosting layer can keep an older HTML document longer than expected. That HTML may contain references associated with release A while the origin only contains release B.

This is particularly dangerous when HTML receives a long immutable policy by mistake. Hashed JavaScript and HTML should not be treated as the same caching object. The chunk can be immutable because its URL is versioned by content. The HTML is the thing that chooses which chunk URLs belong together.

3. A rolling or multi-instance deployment serves mixed releases

Imagine two Next.js instances behind a load balancer. One is already on release B; the other is still on A. A document can come from one release while a later navigation request lands on the other. The current Next.js documentation calls this version skew and notes that it can produce missing assets, Server Function mismatches, and navigation failures.

The safest default is to build once and run the same build artifact on every instance participating in one deployment. The Next.js self-hosting documentation also recommends using the same build and a consistent build ID across containers rather than independently rebuilding each replica.

4. The deployment itself is published in the wrong order

Even without long-lived tabs, a non-atomic upload can create a temporary impossible state:

new HTML is visible
+
new chunk files are not visible yet

or the reverse:

old HTML is still visible
+
old chunk files were already deleted

A short window is enough. A user only has to arrive during it once.

The dangerous deployment pattern is “replace everything and delete the old tree”

A simple deployment script often starts life as something like this:

build
rsync --delete new-output/ production/
restart

That is attractive because the production directory always matches the newest build exactly. It is also hostile to long-lived clients.

With hashed static assets, cleaning the directory to exactly one release provides little benefit to the browser. Old files do not conflict with new ones because their URLs are different. Deleting them mainly saves disk space. But it converts every still-valid reference from an older client into a potential 404.

I now think of old chunks as deployment compatibility material, not garbage.

That does not mean keeping every build forever. It means cleanup should be a separate retention policy, not an incidental side effect of publishing the newest release.

Retention is useful, but no finite retention window is a complete solution

A practical self-hosted setup can keep old /_next/static assets for a grace period. The exact duration is workload-dependent. A site where users open a page, read for two minutes, and leave has a different risk profile from an application that people keep open all day.

A useful way to reason about the minimum retention window is:

retention window >=
    expected HTML staleness
    + realistic long-lived-tab window
    + rollback window
    + deployment propagation margin

This is not a mathematical guarantee. A browser tab can remain open for weeks. No finite number of hours makes old-client failures impossible.

That is why I prefer a layered design:

  1. keep prior immutable assets long enough that normal old sessions continue to work;
  2. detect version skew so the client can move to the current release;
  3. provide a safe one-shot reload or user-visible recovery path when an asset is genuinely unavailable;
  4. monitor missing first-party chunks so retention can be tuned from real evidence.

The retention layer prevents most failures. The recovery layer handles the tail that retention can never eliminate completely.

Do not garbage-collect old chunks with a blind age rule

A naive cleanup such as “delete every file older than seven days” can also be wrong. A current release might reuse an older hashed file whose modification time is old because the content did not change.

A stronger garbage-collection model is release-aware:

  1. retain the manifests or asset inventories for every release still inside the compatibility window;
  2. build the union of asset paths referenced by those releases;
  3. never delete anything in that protected set;
  4. only remove unreferenced assets after an additional grace period.

If that is too much machinery for a small deployment, an intentionally generous static-asset directory is often cheaper than debugging rare client failures. Hashed files are especially suitable for this because duplicate content naturally reuses stable URLs or at least cannot overwrite unrelated content under the same hashed name.

The rule I would avoid is simple: do not make --delete on the shared /_next/static tree part of the same operation that promotes the new release.

Next.js now has explicit version-skew protection, but it is not old-asset storage

Current Next.js supports a deploymentId for version-skew protection. The configuration can look like this:

// next.config.js
const nextConfig = {
  deploymentId: process.env.DEPLOYMENT_VERSION,
}

module.exports = nextConfig

According to the current Next.js deploymentId documentation, configuring it causes framework-managed static asset URLs to receive a ?dpl=<deploymentId> parameter, client-side navigation requests to carry deployment information, and the server to signal its deployment ID in responses. When Next.js detects a mismatch during navigation, it can perform a hard navigation rather than continue a soft navigation with incompatible data.

?dpl=<deploymentId>
x-deployment-id
x-nextjs-deployment-id
data-dpl-id

That is valuable, but it is important not to attribute more power to the feature than it has. The documentation explicitly says Next.js does not use the incoming ?dpl= parameter for version-aware routing. The parameter is used for cache busting. If a self-hosted origin has physically deleted an old asset, a query parameter does not recreate the file.

So I treat deploymentId as a skew detector and recovery mechanism, not as a replacement for deployment hygiene or asset retention.

On platforms that implement deployment-aware routing, the infrastructure can go further. For example, Vercel's current Skew Protection documentation describes version locking so framework-managed requests can continue to resolve against the deployment that served the client. That is a platform capability, not something I assume exists on an arbitrary Nginx or CDN setup.

Build ID and deployment ID solve related but different problems

Next.js also generates a build ID during next build. If multiple containers are supposed to serve the same deployment, they should not quietly become different builds because each server ran its own independent build step.

A deterministic build ID can be tied to a release identifier such as a Git commit:

// next.config.js
const nextConfig = {
  generateBuildId: async () => process.env.GIT_SHA,
  deploymentId: process.env.GIT_SHA,
}

module.exports = nextConfig

This example is illustrative, not copied from my production code. The important architectural rule is that one logical release should have one coherent build artifact and one deployment identity across all instances that serve it.

generateBuildId identifies the Next.js build. deploymentId is specifically documented for version-skew protection and cache busting. They are related, but treating the names as synonyms makes debugging harder.

I would publish assets before switching traffic to the new document

A safer deployment sequence is deliberately asymmetric. New immutable assets can exist before anybody references them. New HTML should not reference assets that are not yet available.

Conceptually, I want this order:

1. build release B once
2. upload B's /_next/static assets
3. verify required assets are actually retrievable
4. start or prepare B's server/runtime
5. health-check B
6. atomically switch new document traffic to B
7. keep A's static assets available
8. monitor B
9. garbage-collect old assets later

If the application is a static export, the same principle applies: upload versioned assets first and publish the HTML that references them second. If it is SSR behind a reverse proxy, prepare the new server and only switch traffic when it is healthy.

Rollback should be symmetrical. Keeping the previous release directory and its static assets makes an application rollback possible without trying to reconstruct old files from memory.

This does not make every rollback safe. A database migration or incompatible backend contract can prevent an old application version from working even when its JavaScript still exists. Static-asset retention solves the static compatibility problem, not every release-compatibility problem in the system.

A shared immutable asset directory works well for simple self-hosting

For a small Nginx-based deployment, one straightforward pattern is to separate the current application release from a shared static-asset store.

An illustrative layout might be:

/srv/app/releases/2026-08-13-a/
/srv/app/releases/2026-08-13-b/
/srv/app/current -> /srv/app/releases/2026-08-13-b/

/srv/app/shared/_next/static/...

Each deployment adds its new /_next/static files to the shared directory without deleting files from earlier retained releases. Nginx can serve that path with an immutable policy:

location ^~ /_next/static/ {
    root /srv/app/shared;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

This configuration is an example, not a claim about the exact Nginx configuration I used. Real deployments need to account for permissions, MIME types, compression variants, CDN behavior, and the exact output layout.

The important part is architectural: the mutable pointer to the current release and the append-mostly store of versioned assets have different lifecycles.

HTML needs a different cache policy from hashed chunks

The easiest way to recreate the problem is to cache HTML as if it were a content-hashed asset.

For dynamically rendered Next.js pages, the framework normally uses non-cacheable response semantics for user-specific dynamic output. Static and ISR pages follow different policies, and a CDN can legitimately cache them. A static export served by Nginx is even more dependent on whatever headers the operator configures.

So I do not use one universal caching rule for “the website.” I think in object classes:

hashed /_next/static asset
    long max-age
    immutable
    safe to retain

HTML / route document
    must be able to move to the new release
    policy depends on rendering model
    must not outlive the assets it references

RSC / navigation / API data
    separate compatibility and freshness rules

If a CDN is involved, purging the new document path can be necessary after a deployment depending on the caching design. Purging old hashed chunks merely because a new release exists is usually counterproductive: if the origin has also deleted them, the purge removes the last copy that might have saved an old client.

The Next.js CDN caching guide is useful here because it separates page caching from the one-year immutable policy used by /_next/static assets.

Automatic reload is a recovery tool, not the primary deployment strategy

A common response to a chunk failure is “just reload the page.” That often works because a hard navigation fetches the current document, which references the current build.

But blindly reloading on every script error creates new problems:

  • a third-party script failure can trigger a pointless reload;
  • a genuine server outage can create an infinite reload loop;
  • an unsaved form can lose user input;
  • React component state disappears on a hard navigation;
  • the same broken deployment can simply fail again.

The current Next.js documentation itself warns that hard navigation used for skew recovery can lose component state such as useState, while state stored in the URL or persistent browser storage can survive.

If I add client-side recovery, I want it narrow and one-shot. An illustrative implementation could look like this:

const RECOVERY_KEY = 'next-chunk-recovery-attempted'

function isOwnNextAsset(url: string) {
  try {
    const parsed = new URL(url, window.location.href)
    return (
      parsed.origin === window.location.origin &&
      parsed.pathname.startsWith('/_next/static/')
    )
  } catch {
    return false
  }
}

window.addEventListener(
  'error',
  (event) => {
    const target = event.target
    if (!(target instanceof HTMLScriptElement)) return
    if (!isOwnNextAsset(target.src)) return

    reportChunkFailure({
      page: window.location.href,
      asset: target.src,
    })

    if (sessionStorage.getItem(RECOVERY_KEY)) return

    sessionStorage.setItem(RECOVERY_KEY, '1')
    window.location.reload()
  },
  true,
)

This is intentionally only an example. A production implementation should also consider stylesheet chunks, known framework error shapes, user workflows where reload is destructive, and how the recovery marker is cleared after a healthy load.

For an editor, checkout flow, or long form, I may prefer a banner saying “A new version is available; save your work and reload” instead of forcing a refresh.

The monitoring payload should tell me whether this is actually deployment skew

A message that says only “failed to load script” is not enough. To distinguish a deleted old chunk from a random network failure, I want deployment-aware context.

Useful fields include:

  • the failed asset URL;
  • current page URL;
  • whether the resource is first-party;
  • the release or deployment identifier visible to the client;
  • browser and operating system;
  • navigator.onLine as a weak signal, not proof of connectivity;
  • time since page load;
  • whether the error happened shortly after a deployment;
  • whether this was the first recovery attempt;
  • HTTP status when it can be observed server-side;
  • the release currently serving the request at the origin or proxy.

The patterns then become much more informative.

If many users on different networks request old hashed chunk URLs and the origin returns 404 immediately after a release, missing retained assets becomes a strong explanation. If only one user sees a network-level failure with no HTTP response, deployment skew is much less certain. If the chunk returns 200 with the wrong MIME type or an HTML error page, the problem is routing or proxy configuration, not merely retention.

I would also alert on first-party chunk failures separately from third-party resource failures. That is the monitoring change most directly supported by my original logs: the meaningful signal was being mixed with a large amount of unrelated browser noise.

The reproduction test is simple, but it must preserve the old tab

This class of bug is easy to miss in normal release testing because engineers tend to refresh immediately after deploying. Refreshing destroys the exact condition we are trying to test.

A better manual test is:

  1. deploy release A;
  2. open a production-like tab with browser caching enabled;
  3. visit only part of the application so some routes or lazy features remain unloaded;
  4. leave that tab open;
  5. deploy release B;
  6. do not refresh the old tab;
  7. trigger a route or dynamic feature that requires code not previously loaded;
  8. inspect Network and Console;
  9. verify whether old asset URLs still return 200;
  10. verify whether version-skew detection performs a controlled hard navigation when appropriate.

I would repeat the same test with a CDN in front, with two server instances during a rolling deployment, and after the configured retention window expires.

One subtle testing mistake is enabling “Disable cache” in DevTools for everything. That can be useful for some diagnostics, but it changes browser behavior. The long-lived-tab scenario should also be tested with realistic caching because the browser cache is part of the system.

Not every chunk failure is fixed by keeping old files

Retention is powerful precisely because it solves one narrow mechanism. It should not become another universal explanation.

A first-party chunk can fail because:

  • the request never reached the server;
  • the connection was interrupted;
  • a browser extension blocked it;
  • a CDN edge had a transient failure;
  • Nginx routed the path incorrectly;
  • the server returned an HTML error document instead of JavaScript;
  • compression or content-encoding was corrupted;
  • the file permissions were wrong;
  • a partial deployment never uploaded the chunk;
  • the file existed but was removed too early;
  • the client and server were on incompatible deployments.

The response code and timing matter. A repeated 404 for an old content-hashed URL after every release tells a very different story from ERR_CONNECTION_RESET on one mobile network.

This is why I would not rewrite my original incident as “I proved stale HTML broke the site.” I did not. I observed a real first-party chunk failure and identified deployment skew as one serious failure mode worth designing out.

The safest deployment treats old clients as part of the release surface

The deeper mistake is thinking that a deployment replaces version A with version B at one instant.

On the server, that may be what the symlink or container orchestrator says happened. On the network, old CDN objects may still exist. In browsers, documents from A may continue running long after B is live. During a rolling release, both server versions may be active. During rollback, B may disappear and A may become current again.

So the real release surface is a time interval, not a point.

My deployment rules for a Next.js application are now built around that idea:

  • Build once per logical release. Do not let replicas quietly produce unrelated build outputs.
  • Publish immutable assets before publishing references to them.
  • Keep old hashed assets for a deliberate compatibility window.
  • Do not give mutable HTML the same cache policy as hashed chunks.
  • Use deploymentId when the deployment model can experience version skew.
  • Use platform-level skew protection when the hosting platform actually provides version-aware routing.
  • Make recovery one-shot and state-aware.
  • Monitor first-party chunk failures as a separate production signal.
  • Test a deployment with an old tab still open.
  • Garbage-collect old assets later, not during promotion.

The rule I use now

A green build and a healthy fresh page do not prove that a deployment is safe for users who were already there.

The old tab is not stale debris. It is a real client running a real previous release.

Once I started thinking about deployments this way, the chunk problem became less mysterious. A content hash gives an asset a stable identity. Long-lived caching makes that identity efficient. But the deployment has to honor the identity for long enough, or provide a controlled way for the client to move forward.

I do not need every old release to live forever. I need the system to survive the period in which old clients and new servers legitimately coexist.

That is the deployment contract I care about now: new users get the new release, old users do not lose the files their current release still knows how to request, and any remaining version mismatch fails into a deliberate recovery path instead of a broken page.