The ChunkLoadError, often surfaced in browser consoles as "Loading chunk [hash] failed" (webpack) or "Failed to load chunk /_next/static/chunks/..." (Turbopack), is a common production failure in Next.js applications using the App Router. This guide is a diagnosis-first playbook rather than a single silver-bullet fix, covering at least five distinct root causes and providing a structured diagnostic decision tree for every occurrence.
Table of Contents
- Prerequisites
- Why Chunk Load Errors Haunt Production
- How Next.js Code-Splitting Works (and Where It Breaks)
- Diagnostic Decision Tree
- Fix 1: Eliminate Deploy Skew with deploymentId
- Fix 2: Audit Your Middleware Matcher
- Fix 3: CDN Caching and Cache-Header Hygiene
- Fix 4: Client-Side Error Boundary with Reload-Once Logic
- Fix 5: Service Workers and Offline Caching Conflicts
- Post-Deploy Checklist
- Matching Symptoms to Root Causes
Prerequisites
This guide covers the App Router in Next.js 14 through 16 (the current release is 16.3.8 as of October 4, 2026) and uses the Node.js version your Next.js release requires (Next.js 16 needs Node.js 20.9 or later). Fix 5 examples use Workbox 7.x. Verify your versions before applying any changes:
next --version
node --version
Why Chunk Load Errors Haunt Production
The ChunkLoadError, often surfaced in browser consoles as "Loading chunk [hash] failed" (webpack) or "Failed to load chunk /_next/static/chunks/..." (Turbopack), is a common production failure in Next.js applications using the App Router. It fires when the browser tries to fetch a code-split JavaScript chunk via a dynamic import() call and receives either a non-200 response or content that cannot be parsed as JavaScript.
The result: broken client-side navigation and blank screens. Users stop trusting client-side navigation entirely.
This error does not reproduce locally. Local development runs a single deployment in a single environment with no CDN layer, no reverse proxy, and no stale cached HTML documents referencing outdated chunk hashes.
This error does not reproduce locally. Local development runs a single deployment in a single environment with no CDN layer, no reverse proxy, and no stale cached HTML documents referencing outdated chunk hashes. The conditions that trigger the failure in production simply do not exist on localhost:3000.
This guide is a diagnosis-first playbook rather than a single silver-bullet fix. The error has at least five distinct root causes, and applying the wrong fix wastes time while users continue to hit broken pages. The diagnostic decision tree below provides a structured starting point for every occurrence.
How Next.js Code-Splitting Works (and Where It Breaks)
Hash-Based Chunk URLs
Next.js automatically code-splits application bundles into discrete files stored under _next/static/chunks/. Next.js gives each file a content-hashed filename, meaning the hash changes whenever the file's contents change. During client-side navigation, the App Router triggers dynamic import() calls to fetch only the chunks required for the target route. This reduces initial transfer size by deferring non-critical code, but it introduces a dependency: the chunk URL embedded in the currently loaded JavaScript must resolve to a valid JavaScript file on the server or CDN.
The Three Failure Points
Every ChunkLoadError traces back to one of three locations in the request lifecycle:
- Origin or CDN. The chunk file is absent from the server, typically because a new deployment replaced the
_next/static/directory and the old hashed files no longer exist. Alternatively, the CDN returns the wrong content (such as an HTML error page) for the chunk URL. - Network path. A proxy, middleware layer, or Web Application Firewall intercepts the chunk request and returns something other than the expected JavaScript. This includes authentication middleware that redirects unauthenticated requests to a login page and corporate proxies that inject block pages.
- Client cache. The browser holds a stale HTML document that references chunk hashes from a previous deployment. When the user navigates, the
import()call requests a hash that no longer exists on the current origin.
Diagnostic Decision Tree
The following numbered flowchart provides a systematic path from symptom to root cause. Each terminal branch references the corresponding fix section below.
- Can the failing chunk URL be captured? Open the browser console, locate the full URL in the
ChunkLoadErrormessage (e.g.,https://example.com/_next/static/chunks/app/dashboard-a1b2c3d4.js), and copy it. - Inspect the failing URL with
curl. Runcurl -sIto inspect response headers (status code,content-type,cache-control), then runcurl -sto confirm the response body begins with JavaScript, not HTML (e.g., the first bytes should not be| head -c 80 or{"error").- Non-200 status, wrong content-type, or HTML body → proceed to the CDN / Origin branch (step 3).
- 200, correct content-type, and JavaScript body → proceed to the Client / Proxy branch (step 4).
- CDN / Origin branch:
- Is there deploy skew? Check whether the chunk hash in the URL matches the currently deployed build. → See Fix 1: Eliminate Deploy Skew with
deploymentId. - Has the CDN cache been purged after deploy? Check for stale cached 404s or stale HTML error pages. → See Fix 3: CDN Caching and Cache-Header Hygiene.
- Are
_next/static/assets served withimmutablecache headers? → See Fix 3.
- Is there deploy skew? Check whether the chunk hash in the URL matches the currently deployed build. → See Fix 1: Eliminate Deploy Skew with
- Client / Proxy branch:
- Does middleware intercept
_next/static/*paths? Test by curling with and without auth cookies. → See Fix 2: Audit Your Middleware Matcher. - Is a service worker serving stale chunks from a precache? Check the Application panel in DevTools. → See Fix 5: Service Workers and Offline Caching Conflicts.
- Is an ad-blocker or corporate proxy rewriting the response? Test from a clean browser profile.
- Does middleware intercept
The following curl commands provide the header and body checks referenced above. Replace the placeholder URLs with your actual chunk URLs:
# Check status code, content-type, and cache headers
curl -sI https://example.com/_next/static/chunks/app/dashboard-a1b2c3d4.js
# Verify the response body starts with JavaScript, not HTML
curl -s https://example.com/_next/static/chunks/app/dashboard-a1b2c3d4.js | head -c 80
# Check deployment ID header if applicable
curl -s -D - -o /dev/null https://example.com/_next/static/chunks/app/dashboard-a1b2c3d4.js \
| grep -iE "HTTP/|content-type|cache-control|x-nextjs-deployment-id"
# Compare response with and without auth cookies
# Replace session=abc123 with a valid session cookie from your browser's DevTools → Application → Cookies
curl -sI -b "session=abc123" https://example.com/_next/static/chunks/app/dashboard-a1b2c3d4.js
curl -sI https://example.com/_next/static/chunks/app/dashboard-a1b2c3d4.js
If the authenticated and unauthenticated responses differ in status code or content-type, middleware is the likely culprit.
Fix 1: Eliminate Deploy Skew with deploymentId
What Deploy Skew Is
Deploy skew occurs when users have open browser tabs holding an HTML document from a previous deployment. That HTML contains tags and inline JavaScript referencing chunk hashes the old build generated. When the user triggers a client-side navigation, the App Router issues a dynamic import() for a chunk URL like _next/static/chunks/app/settings-x9y8z7.js. If a new deployment has replaced the _next/static/ directory in the interim, that file no longer exists on the origin. The import() call receives a 404 (or an HTML error page), and the browser throws a ChunkLoadError.
This is the single most common cause of the error, and it affects any team whose users hold tabs open across deploys on a stateless hosting environment.
Configuring deploymentId in next.config.js
The deploymentId option became a stable top-level config option in Next.js 14.1.4 (it was experimental.deploymentId from 13.4.10). You can also set it with the NEXT_DEPLOYMENT_ID environment variable. When set, Next.js appends a ?dpl= query parameter to all static asset URLs. This allows the framework to detect when the client's deployment ID no longer matches the server's current deployment. On mismatch, Next.js schedules a hard navigation (full page reload) instead of a client-side navigation, ensuring the browser fetches fresh HTML with current chunk references.
// next.config.js
const deploymentId =
process.env.NEXT_DEPLOYMENT_ID || process.env.VERCEL_GIT_COMMIT_SHA;
if (!deploymentId) {
// Fail loudly during build so misconfiguration is caught in CI, not production.
// Remove this block if you intentionally disable deploymentId in local development.
console.warn(
'[next.config.js] WARNING: Neither NEXT_DEPLOYMENT_ID nor VERCEL_GIT_COMMIT_SHA ' +
'is set. Deploy skew protection is disabled. Set one of these variables in your ' +
'CI/CD pipeline to enable it.'
);
}
/** @type {import('next').NextConfig} */
const nextConfig = {
...(deploymentId ? { deploymentId } : {}),
};
module.exports = nextConfig;
Set the NEXT_DEPLOYMENT_ID environment variable to a value unique to each deployment, such as the Git commit SHA or a CI build ID. This ensures every deploy produces distinct asset URLs. On non-Vercel platforms, substitute GITHUB_SHA, CI_COMMIT_SHA, or your CI system's equivalent commit variable, since VERCEL_GIT_COMMIT_SHA is only automatically set on Vercel.
Limitations to Understand
The deploymentId option does not keep old chunks available on the server. It detects the mismatch and schedules a hard reload, but the browser may already throw the ChunkLoadError before this detection completes. The reload is the recovery mechanism, not a prevention mechanism. Fix 4 (error boundary) is required to handle the window between the error being thrown and the reload being triggered.
Routing requests by deployment ID to serve assets from the correct build requires host-level or CDN-level support. Vercel provides this natively through its Skew Protection feature, which routes asset requests to the deployment that generated them. For self-hosted environments, custom origin routing logic is necessary to achieve the same effect, such as maintaining multiple build output directories keyed by deployment ID.
Full documentation is available in the Next.js configuration reference for deploymentId. Next.js does not route requests on ?dpl=, so serving each client the assets of its own deployment needs support from your host or CDN. The Next.js maintainers recommend turning on skew protection on your platform, and note that chunk retry logic ships by default for Turbopack in Next.js 16.3, which reduces but does not eliminate chunk load errors (see this Next.js discussion).
Fix 2: Audit Your Middleware Matcher
The Silent Interceptor Problem
Naming note: in Next.js 16 the middleware file convention was renamed to proxy (proxy.ts, exporting a proxy function). In Next.js 15 and earlier, keep the file named middleware.ts and export middleware. The matcher advice is the same for both, and the Proxy documentation confirms that without a matcher it runs on every request, including _next/static.
Next.js middleware (Proxy in Next.js 16) runs on every request by default unless a matcher configuration restricts its scope. Overly broad matchers, or the absence of a matcher entirely, cause middleware to intercept requests to _next/static/* paths. When middleware performs authentication checks and issues a redirect for unauthenticated users, the browser receives an HTML redirect response (often a 307 with a Location header, or a 200 containing an HTML login page) instead of the expected JavaScript chunk. The browser attempts to parse this HTML as JavaScript and throws a ChunkLoadError.
This failure mode is especially difficult to diagnose because it only affects users in specific authentication states and the chunk URL itself returns a valid 200 when tested with the correct cookies.
A Safe Matcher Pattern
The following proxy.ts (or middleware.ts) configuration uses a negative-lookahead regex to exclude all static asset paths, image optimization routes, and common static files from middleware processing:
// proxy.ts (Next.js 16+). In Next.js 15 and earlier, name this file middleware.ts
// and export function middleware instead of function proxy.
// WARNING: Replace the body of this function with your actual middleware logic before deploying.
// As written, this stub passes all requests through without any authentication or redirect checks.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function proxy(request: NextRequest) {
// Your authentication / redirect logic here
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths EXCEPT:
* - _next/static (static files)
* - _next/image (image optimization)
* - favicon.ico (browser favicon)
* - public folder assets (images, fonts, etc.)
*
* - common static file extensions
*/
'/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$).*)',
],
};
This pattern ensures that requests to _next/static/chunks/, _next/static/css/, and _next/image/ bypass middleware entirely.
Verifying the Fix
After updating the matcher, verify the fix by curling the chunk URL both with and without authentication cookies. Both requests should return HTTP 200 with content-type: application/javascript. If the unauthenticated request still returns HTML or a redirect, the matcher is not correctly excluding the path.
Fix 3: CDN Caching and Cache-Header Hygiene
Required Cache Headers for _next/static/
| Path Pattern | Cache-Control |
Content-Type |
Notes |
|---|---|---|---|
_next/static/chunks/*.js |
public, max-age=31536000, immutable |
application/javascript |
Content hash guarantees uniqueness; safe to cache forever |
_next/static/css/*.css |
public, max-age=31536000, immutable |
text/css |
Same rationale as JS chunks |
_next/data/*.json |
s-maxage= |
application/json |
Pages Router only. For ISR routes, set s-maxage equal to your revalidate value. App Router RSC payloads are served from the page URL path, not _next/data/. |
| HTML pages | no-cache or short TTL (e.g., s-maxage=60) |
text/html |
Must always fetch fresh to get current chunk references |
The critical rule: HTML pages must never be cached aggressively. If a CDN serves stale HTML containing old chunk hashes, every user who receives that cached HTML will hit a ChunkLoadError on their next client-side navigation after a deploy.
Common CDN Misconfigurations
Aggressive HTML caching. Verify your CloudFront distribution's cache policy, as default behavior depends on whether a managed or custom cache policy is applied. If the Next.js application does not set no-cache or a short TTL on HTML responses, CloudFront may serve stale HTML for the duration of its configured TTL. Nginx proxy_cache exhibits the same behavior when configured without path-specific rules.
Stripping query strings. Some CDN configurations strip query parameters from cache keys. This breaks the ?dpl= mechanism introduced by deploymentId, causing all deployments to share a single cache entry for each chunk path.
Missing invalidation after deploy. Without a cache invalidation step in the deployment pipeline, the CDN continues serving stale 404 responses for new chunk URLs (or stale 200 responses for old HTML) until the TTL expires. For CloudFront with default settings, this can mean up to 24 hours.
# Nginx: Define a cache zone at the http block level
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=static_cache:10m max_size=1g inactive=365d;
# Nginx: Correct caching for _next/static/ assets
location /_next/static/ {
proxy_pass http://nextjs_upstream;
proxy_cache static_cache;
proxy_cache_valid 200 365d;
proxy_cache_valid 404 0s; # Do not cache 404s — prevents negative caching of new chunks
proxy_cache_valid any 0s; # Do not cache other status codes
proxy_hide_header Cache-Control; # Prevent upstream header from being forwarded
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Cache-Status $upstream_cache_status always;
}
# Nginx: Prevent aggressive caching of HTML pages
# proxy_cache off ensures Nginx does not cache HTML responses from the upstream.
# Cache-Control: no-cache instructs browsers to revalidate on every request.
location / {
proxy_pass http://nextjs_upstream;
proxy_cache off;
proxy_hide_header Cache-Control;
add_header Cache-Control "no-cache" always;
}
For CloudFront, create a cache behavior for the path pattern _next/static/* that uses a policy with query strings included in the cache key and a long TTL. Create a separate behavior for Default (*) with a short TTL or managed cache-disabled policy for HTML.
Fix 4: Client-Side Error Boundary with Reload-Once Logic
Why a Global Error Boundary Matters
Even with all server-side fixes in place, edge cases remain. Flaky mobile networks may drop the chunk response mid-transfer. Corporate proxies may intermittently inject content. A well-designed error boundary turns a white screen into a recoverable state, acting as the last line of defense.
Building the ChunkErrorBoundary Component
// components/ChunkErrorBoundary.tsx
'use client';
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
// Shared pure function — single source of truth for chunk error detection
function isChunkLoadError(error: Error): boolean {
return (
error.name === 'ChunkLoadError' ||
error.message.includes('Loading chunk') ||
error.message.includes('Failed to load chunk') ||
error.message.includes('Failed to fetch dynamically imported module')
);
}
// Single source of truth for sessionStorage key
function getReloadKey(pathname: string): string {
return `chunk-reload-${pathname}`;
}
function safeSessionGet(key: string): string | null {
try {
return sessionStorage.getItem(key);
} catch {
return null;
}
}
function safeSessionSet(key: string, value: string): void {
try {
sessionStorage.setItem(key, value);
} catch {
// Private browsing or sandboxed iframe — cannot persist reload flag.
// Acceptable: user may see one extra reload attempt.
}
}
function safeSessionRemove(key: string): void {
try {
sessionStorage.removeItem(key);
} catch {
// Ignore
}
}
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
isChunkError: boolean;
}
class ChunkErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, isChunkError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, isChunkError: isChunkLoadError(error) };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
if (isChunkLoadError(error)) {
const reloadKey = getReloadKey(window.location.pathname);
const hasReloaded = safeSessionGet(reloadKey);
if (!hasReloaded) {
safeSessionSet(reloadKey, 'true');
window.location.reload();
return; // Reload dispatched; do not log or fall through
}
// Already reloaded once — fall through to render fallback UI
return;
}
// Non-chunk errors: log to error reporting service
console.error('Error boundary caught:', error, errorInfo);
}
handleManualRetry = () => {
safeSessionRemove(getReloadKey(window.location.pathname));
window.location.reload();
};
render() {
if (this.state.hasError && this.state.isChunkError) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h2>This page failed to loadh2>
<p>A new version of the application may be available.p>
<button onClick={this.handleManualRetry}>Reload Pagebutton>
div>
);
}
if (this.state.hasError) {
return <div style={{ padding: '2rem' }}>Something went wrong.div>;
}
return this.props.children;
}
}
export default ChunkErrorBoundary;
The sessionStorage flag prevents infinite reload loops: if the first automatic reload does not resolve the error, the boundary renders a user-friendly fallback with a manual retry button instead of reloading again. The reload key is scoped to the current pathname so that a failed reload on one route does not suppress auto-reload on a different route within the same session. All sessionStorage access is wrapped in try/catch to prevent the boundary itself from crashing in private browsing modes or sandboxed iframes where sessionStorage throws a SecurityError.
Integrating with app/layout.tsx
To use ChunkErrorBoundary in the App Router, wrap the layout's children. Note that class-based error boundaries must be Client Components ('use client' is already set in the component file):
// app/layout.tsx
import ChunkErrorBoundary from '@/components/ChunkErrorBoundary';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ChunkErrorBoundary>
{children}
ChunkErrorBoundary>
body>
html>
);
}
Integrating with App Router global-error.tsx
In the App Router, app/error.tsx catches errors in child segments but does not catch errors thrown in the root layout. Since ChunkLoadError for root-level chunks surfaces at the layout level, the correct file convention is app/global-error.tsx. This file replaces the root layout when it renders, so it must include its own and tags.
// app/global-error.tsx — Note: this file must return its own and tags,
// as it replaces the root layout when an error is caught.
'use client';
import { useEffect } from 'react';
function isChunkLoadError(error: Error): boolean {
return (
error.name === 'ChunkLoadError' ||
error.message.includes('Loading chunk') ||
error.message.includes('Failed to load chunk') ||
error.message.includes('Failed to fetch dynamically imported module')
);
}
function getReloadKey(pathname: string): string {
return `chunk-reload-${pathname}`;
}
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log every global error for observability
console.error('[GlobalError] boundary activated:', error.name, error.message, error.digest);
if (!isChunkLoadError(error)) return;
const reloadKey = getReloadKey(window.location.pathname);
try {
const hasReloaded = sessionStorage.getItem(reloadKey);
if (!hasReloaded) {
sessionStorage.setItem(reloadKey, 'true');
window.location.reload();
}
} catch {
// sessionStorage unavailable (private mode / sandboxed iframe) — skip auto-reload
}
}, [error.name, error.message]);
function handleReset() {
try {
sessionStorage.removeItem(getReloadKey(window.location.pathname));
} catch {
// Ignore
}
reset();
}
return (
<html>
<body>
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h2>Something went wrongh2>
<button onClick={handleReset}>
Try Again
button>
div>
body>
html>
);
}
The App Router's global-error.tsx convention provides the reset function, which attempts to re-render the segment. Combining reset with the reload-once pattern covers both recoverable React errors and chunk load failures.
Fix 5: Service Workers and Offline Caching Conflicts
Stale Precache Manifests
Applications using Workbox or next-pwa often precache chunk URLs as part of their service worker installation step. After a new deployment, the service worker may continue serving stale or missing chunks from its cache, bypassing the network entirely. The browser receives outdated JavaScript (or a cache miss that resolves to nothing), producing a ChunkLoadError.
The fix involves configuring Workbox to clean up outdated caches and carefully manage service worker activation. Enable cleanupOutdatedCaches: true in the Workbox configuration. Set skipWaiting: false and notify users of the update via a controllerchange event listener so they can reload voluntarily. Using skipWaiting: true forces the new service worker to activate immediately, which can itself cause ChunkLoadError in tabs that are mid-navigation - the old tab's cached chunks become mismatched with the new service worker's manifest. Use clientsClaim only after a user-initiated reload.
Instead of auto-activating the new service worker, display an "Update available - please reload" prompt. When using this in a React component, register the listener inside a useEffect with proper cleanup to avoid stacking duplicate listeners across mounts:
// hooks/useServiceWorkerUpdateNotification.ts
import { useEffect } from 'react';
export function useServiceWorkerUpdateNotification(
onUpdate: () => void
): void {
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
const handleControllerChange = () => {
onUpdate();
};
navigator.serviceWorker.addEventListener('controllerchange', handleControllerChange);
return () => {
navigator.serviceWorker.removeEventListener('controllerchange', handleControllerChange);
};
}, [onUpdate]); // onUpdate must be stable (useCallback at call site)
}
Quick Diagnostic
To determine whether a service worker is involved, open DevTools → Application → Service Workers (available in all major browsers). In Chromium-based browsers, you can also navigate to chrome://serviceworker-internals/ for additional detail. If a service worker is registered and active, test by unregistering it and reloading the page. If the ChunkLoadError disappears, the service worker caching strategy is confirmed as the root cause.
Post-Deploy Checklist
- Set
deploymentIdinnext.config.jsand verify that?dpl=appears in asset URLs in the page source. - Audit the matcher in
proxy.ts(middleware.tson Next.js 15 and earlier); confirm_next/static/paths are excluded. - Validate CDN cache headers: run
curl -sIagainst a chunk URL andcurl -sto verify the body. Confirm| head -c 80 application/javascriptandimmutable. - Confirm HTML pages are served with
no-cacheor a short TTL, neverimmutable. - Deploy the
ChunkErrorBoundarycomponent andglobal-error.tsxas a safety net for unrecoverable edge cases. - Audit service worker caching strategy; enable
cleanupOutdatedCachesand useskipWaiting: falsewith a user-facing update prompt.
Matching Symptoms to Root Causes
ChunkLoadErroris a symptom with multiple possible causes spanning deploy skew, misconfigured middleware, CDN cache policy, and client-side caching layers. No single fix addresses every scenario.
ChunkLoadError is a symptom with multiple possible causes spanning deploy skew, misconfigured middleware, CDN cache policy, and client-side caching layers. No single fix addresses every scenario. The diagnostic decision tree above gives you a repeatable starting point each time the error resurfaces, narrowing the root cause before any code changes are made.

