Fix ChunkLoadError: Loading chunk failed in React Production Build

intermediateโš›๏ธ React2026-05-22| React 16+, webpack 4/5, Create React App, Vite with code-splitting, any production deployment (Nginx, Apache, CDN, S3+CloudFront)

Error Message

ChunkLoadError: Loading chunk 3 failed. (error: https://example.com/static/js/3.chunk.js)
#webpack#code-splitting#react-lazy#production#dynamic-import

The Error

You just deployed. Users start reporting a blank page or a broken feature. The console shows:

ChunkLoadError: Loading chunk 3 failed.
(error: https://example.com/static/js/3.chunk.js)

Sometimes it's chunk 3, sometimes chunk 7, sometimes a named chunk like vendors~main. The browser tried to fetch a JavaScript file and got a 404, a network error, or a cached pointer to a file that no longer exists.

Root Causes

Three things cause this error, and one of them almost certainly explains what you're seeing:

  • Stale HTML referencing old chunk filenames โ€” webpack content-hashes change on every deploy. If a user's browser (or your CDN edge) is still serving the old index.html, it'll request chunk filenames from the previous build โ€” files that are now gone.
  • Chunks missing from the server (404) โ€” the deployment failed silently, or the wrong build folder got uploaded. The HTML loaded fine, but the .chunk.js it references simply isn't there.
  • Wrong public path for dynamic imports โ€” a React.lazy() or import() call resolves to a path that doesn't exist in production. Wrong PUBLIC_URL, subdirectory mismatch, CDN misconfiguration โ€” any of these will do it.

Step 1 โ€” Verify the Chunk Actually Exists

Start with the simplest check. Open the URL from the error message directly in your browser, or curl it:

curl -I https://example.com/static/js/3.chunk.js

A 404 Not Found means the file was never deployed or got overwritten. Check your local build output:

ls -la build/static/js/ | grep chunk

File exists locally but not on the server? Your deployment pipeline has a gap โ€” the upload step missed something. Fix it and redeploy completely before touching anything else.

Step 2 โ€” Fix Stale HTML (The Most Common Cause)

Here's the scenario that trips up most teams: you ship a new build, chunk filenames change (content-hash rotated), but some users are still loading the old index.html. That old HTML references 3.abc123.chunk.js โ€” which no longer exists because the new build produced 3.def456.chunk.js. Every navigation to a lazy route explodes.

Force no-cache on index.html at the server level

For Nginx:

location = /index.html {
  add_header Cache-Control "no-cache, no-store, must-revalidate";
  add_header Pragma "no-cache";
  add_header Expires "0";
}

location /static/ {
  # Chunks are content-hashed โ€” cache them aggressively
  add_header Cache-Control "public, max-age=31536000, immutable";
}

For Apache (.htaccess):

<Files "index.html">
  Header set Cache-Control "no-cache, no-store, must-revalidate"
  Header set Pragma "no-cache"
  Header set Expires 0
</Files>

For AWS CloudFront, create a separate cache behavior for /index.html with TTL = 0.

With this in place, every deploy pushes a fresh index.html to users. Chunk references stay in sync. Problem solved at the source.

Step 3 โ€” Add an Error Boundary with Auto-Reload

Even with proper cache headers, some users will hit the stale-chunk window between deploy and CDN propagation. An error boundary catches the failure and reloads once โ€” silently fixing it for most people.

ChunkLoadErrors bubble up as uncaught promise rejections from dynamic imports. Wrapping lazy routes in a boundary lets you intercept them:

// ChunkErrorBoundary.tsx
import React, { Component, ErrorInfo } from 'react';

interface State {
  hasError: boolean;
  reloadAttempted: boolean;
}

export class ChunkErrorBoundary extends Component<React.PropsWithChildren, State> {
  state: State = { hasError: false, reloadAttempted: false };

  static getDerivedStateFromError(error: Error): Partial<State> {
    if (error.name === 'ChunkLoadError') {
      return { hasError: true };
    }
    return {};
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    if (error.name === 'ChunkLoadError' && !this.state.reloadAttempted) {
      this.setState({ reloadAttempted: true });
      // Auto-reload once to pick up fresh chunks
      window.location.reload();
    }
  }

  render() {
    if (this.state.hasError) {
      return (
        <div>
          <p>Failed to load this section.</p>
          <button onClick={() => window.location.reload()}>Reload</button>
        </div>
      );
    }
    return this.props.children;
  }
}

Wrap your lazy routes:

const Dashboard = React.lazy(() => import('./pages/Dashboard'));

function App() {
  return (
    <ChunkErrorBoundary>
      <Suspense fallback={<Spinner />}>
        <Dashboard />
      </Suspense>
    </ChunkErrorBoundary>
  );
}

Step 4 โ€” Check PUBLIC_URL and CDN Base Path

Deployed to a subdirectory like /myapp/, or serving assets from a CDN? webpack needs to know the correct public path at build time, or the chunk URLs it generates will point nowhere.

In Create React App, set this in .env.production:

PUBLIC_URL=https://cdn.example.com/myapp

In a custom webpack config:

// webpack.config.js
module.exports = {
  output: {
    publicPath: process.env.PUBLIC_URL || '/',
    // webpack 5 auto mode works well for most setups:
    // publicPath: 'auto',
  },
};

For Vite:

// vite.config.ts
export default defineConfig({
  base: '/myapp/', // must match the deployment path exactly
});

Get this wrong by even one character and every chunk URL in the bundle will point to the wrong domain or path.

Step 5 โ€” Handle Chunk Load Failure at the Import Level

For critical lazy imports, add a retry wrapper. Mobile users on spotty 4G connections will thank you โ€” transient network blips cause chunk fetches to fail even when the file is right there on the server.

function retryImport(importFn: () => Promise<any>, retries = 3): Promise<any> {
  return new Promise((resolve, reject) => {
    importFn()
      .then(resolve)
      .catch((error) => {
        if (retries <= 0) {
          reject(error);
          return;
        }
        setTimeout(() => {
          retryImport(importFn, retries - 1).then(resolve, reject);
        }, 1500);
      });
  });
}

// Usage
const Dashboard = React.lazy(() => retryImport(() => import('./pages/Dashboard')));

Three retries with a 1.5-second gap is usually enough to ride out a brief connectivity hiccup without frustrating the user.

Verify the Fix

  • Deploy the new build. Open DevTools โ†’ Network tab.
  • Hard-reload (Ctrl+Shift+R / Cmd+Shift+R). Confirm index.html returns 200 with Cache-Control: no-cache in the response headers.
  • Navigate to a lazy-loaded route. Check that the chunk request returns 200 and the filename matches what's in your build/static/js/ folder.
  • Simulate the stale-chunk scenario: deploy a fresh build, then manually request an old chunk URL โ€” it should 404. Reload the page โ€” the fresh index.html should load the correct new chunks cleanly.
  • Check the console. No ChunkLoadError. Done.

Prevention

  • Set no-cache on index.html before your first production deploy โ€” not after the first incident. It takes five minutes and saves hours of on-call pain.
  • Purge CDN cache after every deploy. Add it to your pipeline: aws cloudfront create-invalidation --distribution-id XXXXX --paths "/index.html".
  • Name your chunks to make debugging faster: import(/* webpackChunkName: "dashboard" */ './pages/Dashboard') โ€” so the error says dashboard.chunk.js instead of 7.chunk.js.
  • Automate post-deploy verification. After deploying, have CI ping a few chunk URLs and confirm they return 200 before marking the deployment green.

Related Error Notes