Skip to content

Fix: Loading chunk failed / ChunkLoadError

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix 'Loading chunk failed', 'ChunkLoadError', and 'Failed to fetch dynamically imported module' in webpack, Next.js, React, and Vite. Covers stale deployments, CDN caching, publicPath misconfiguration, service worker cache, code splitting, dynamic import retry strategies, React.lazy error boundaries, and Next.js-specific solutions.

The Error

You deploy your app and users start reporting blank pages or broken navigation. The console shows one of these:

Webpack (Create React App, Next.js Pages Router, custom config):

Uncaught ChunkLoadError: Loading chunk 42 failed.
(missing: https://example.com/static/js/42.abc123.chunk.js)
Loading chunk 42 failed.
(error: https://example.com/static/js/42.abc123.chunk.js)

Vite / ES module dynamic imports:

TypeError: Failed to fetch dynamically imported module: https://example.com/assets/Dashboard-abc123.js

React.lazy:

Uncaught ChunkLoadError: Loading chunk 42 failed.

Followed by:

The above error occurred in one of your React components. Consider adding an error boundary.

Next.js:

ChunkLoadError: Loading chunk 42 failed.
Error: Loading chunk pages/dashboard failed.

All of these mean the same thing: your app tried to load a JavaScript file on demand (a “chunk”) and the request failed. The file either doesn’t exist at that URL, returned the wrong content, or was blocked by the network.

Why This Happens

Modern bundlers split your app into multiple JavaScript files called chunks. Instead of loading everything upfront, your app fetches chunks on demand — when a user navigates to a route, opens a modal, or triggers a React.lazy component.

Each chunk filename includes a content hash (like 42.abc123.chunk.js). When you deploy new code, the hashes change. Here’s the problem:

  1. A user loads your app and gets the old index.html.
  2. That HTML references old chunk filenames.
  3. You deploy. The old chunk files are replaced by new ones with different hashes.
  4. The user navigates to a new page. The app requests 42.abc123.chunk.js.
  5. That file no longer exists on the server. The request returns a 404 or the new index.html (which is not JavaScript).
  6. The chunk fails to load.

This is the most common cause, but not the only one:

  • CDN or proxy caching serves stale index.html while origin has new chunks (or vice versa).
  • publicPath is wrong. Chunks are requested from the wrong URL entirely.
  • Service worker caches old HTML and serves it after deployment.
  • Network issues. Corporate proxies, ad blockers, or flaky connections block chunk requests.
  • Filename hashing is disabled. Without hashes, browsers cache old chunk content under the same filename.
  • Build output wasn’t fully uploaded. Partial deployments leave some chunks missing.

How Bundlers Differ on Chunk Loading

Every bundler produces “chunks” but they disagree on filenames, hashing, retry behavior, and how failures surface. If you are migrating between bundlers or running a polyglot stack, the mental model below saves debugging time.

Webpack code splitting. Webpack is where ChunkLoadError originated as a named error type. Chunks are loaded by an injected runtime (__webpack_require__.e) that returns a Promise, and on failure it rejects with ChunkLoadError. Webpack 5 added automatic retry via output.chunkLoadTimeout and a __webpack_chunk_load_retry__ extension point you can wire to a retry function. Filenames default to [id].[contenthash].js which is safe because the hash is content-derived. The main footgun is [hash] (deprecated) vs [contenthash] — only the latter is stable across rebuilds with no content changes.

Vite dynamic import. Vite emits native ES module imports — import('/assets/Dashboard-abc123.js'). Failures surface as TypeError: Failed to fetch dynamically imported module, not ChunkLoadError. There is no built-in retry. Vite’s Rollup-based production build uses [hash] based on content, and base controls the public URL (analog of webpack publicPath). Vite’s preload directive automatically inserts <link rel="modulepreload"> tags for the chunks the entry needs, so a stale index.html after a deploy fails fast on the modulepreload itself rather than on first navigation.

Rspack. Rspack is the Rust-port of webpack and intentionally mirrors webpack’s chunk loading runtime. ChunkLoadError is the same class. If you swapped webpack for Rspack and your chunk-failure code keeps working, that is by design. The hash format defaults to xxhash64 rather than md4, but [contenthash] semantics are preserved.

Turbopack. Turbopack (Next.js’s experimental bundler) emits ES modules similar to Vite in production and uses persistent content hashes per chunk. Errors look like Vite’s Failed to fetch dynamically imported module. Because Turbopack is still stabilizing in Next.js, the exact retry hook surface keeps shifting — pin behavior to the Next.js major version you target and avoid hand-rolling chunk-runtime overrides.

Parcel. Parcel v2 generates ES-module chunks with content hashes and uses native import(). Failures look like Vite failures. Parcel’s runtime does not retry — wrap your imports manually.

Practical takeaway for retries. The retry pattern in Fix 2 (wrap import() in a function that retries on rejection) works identically across all five bundlers because it sits above the runtime, not inside it. If you ship a library that does code splitting, prefer that pattern over relying on a specific bundler’s hook — your library stays portable.

Hash invalidation on deploy. Webpack, Rspack, and Vite produce the same chunk hash for the same input. That means a no-op deploy does not invalidate chunks — users keep their cached files. Turbopack and Parcel also content-hash. The flip side: if your build environment is non-deterministic (timestamp injection, env var differences between CI and local), chunk hashes change on every deploy and your cache hit rate drops to zero. Audit your build for Date.now() or process.env leaks if you see this.

Fix

1. Handle the Stale Deployment Problem (Most Common)

The root cause is usually old HTML referencing chunks that no longer exist after a deployment. The fix has two parts: detect the failure and recover from it.

Force a page reload on chunk failure:

// For webpack (CRA, Next.js Pages Router, custom)
window.addEventListener('error', (event) => {
  if (
    event.message &&
    event.message.includes('Loading chunk') &&
    event.message.includes('failed')
  ) {
    // Avoid infinite reload loops
    const lastReload = sessionStorage.getItem('chunk-reload');
    const now = Date.now();

    if (!lastReload || now - parseInt(lastReload) > 10000) {
      sessionStorage.setItem('chunk-reload', now.toString());
      window.location.reload();
    }
  }
});

This works because reloading fetches the new index.html, which references the correct chunk filenames. The sessionStorage guard prevents infinite reload loops if the error is caused by something other than a stale deployment.

2. Retry Failed Dynamic Imports

Wrap your import() calls with a retry function. This handles transient network failures and gives the user’s browser another chance to fetch the chunk:

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

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

The exponential backoff (delay * 2) avoids hammering the server. Three retries with 1s/2s/4s delays covers most transient failures.

For a reload-on-final-failure variant:

function importWithRetryAndReload(importFn, retries = 3) {
  return retryImport(importFn, retries).catch((error) => {
    const lastReload = sessionStorage.getItem('chunk-reload');
    const now = Date.now();

    if (!lastReload || now - parseInt(lastReload) > 10000) {
      sessionStorage.setItem('chunk-reload', now.toString());
      window.location.reload();
    }

    throw error;
  });
}

3. Add a React Error Boundary for Chunk Failures

React.lazy throws when a chunk fails to load. Without an error boundary, your entire app crashes to a white screen. Wrap lazy components in a boundary that catches chunk errors specifically:

import React from 'react';

class ChunkErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasChunkError: false };
  }

  static getDerivedStateFromError(error) {
    if (error.name === 'ChunkLoadError' ||
        error.message?.includes('Loading chunk') ||
        error.message?.includes('dynamically imported module')) {
      return { hasChunkError: true };
    }
    // Let other errors propagate
    throw error;
  }

  render() {
    if (this.state.hasChunkError) {
      return (
        <div>
          <p>A new version is available.</p>
          <button onClick={() => window.location.reload()}>
            Reload page
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

// Usage
<ChunkErrorBoundary>
  <React.Suspense fallback={<div>Loading...</div>}>
    <Dashboard />
  </React.Suspense>
</ChunkErrorBoundary>

This is better than a silent reload because it gives the user context. They see “A new version is available” instead of a mysterious page refresh.

Why this matters: Chunk load errors are invisible to most monitoring tools because they happen entirely in the browser. Your server returns 200s for the HTML page, and the failed chunk request is a client-side network error. Unless you add explicit error tracking (via an error boundary or a global error handler that reports to your analytics), you will not know users are hitting this until they complain.

4. Fix CDN and Caching Issues

If your CDN caches index.html, users get stale HTML that references old chunks even after deployment.

Set correct cache headers for index.html:

Cache-Control: no-cache, no-store, must-revalidate

Set long cache headers for hashed assets:

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

The hashed filenames (42.abc123.chunk.js) are unique per build, so they can be cached forever. The HTML must never be cached because it’s the entry point that references specific chunk filenames.

For Nginx:

location / {
  # HTML files - never cache
  if ($request_filename ~* \.html$) {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
  }
}

location /static/ {
  # Hashed assets - cache forever
  add_header Cache-Control "public, max-age=31536000, immutable";
}

For Vercel/Netlify: These platforms handle this correctly by default for Next.js and most frameworks. If you’re seeing chunk errors on these platforms, the issue is likely elsewhere.

For CloudFront: Create a cache behavior that uses a managed cache policy with CachingDisabled for index.html, and CachingOptimized for /static/* or /_next/*.

5. Fix publicPath Misconfiguration

If chunks are requested from the wrong URL entirely, publicPath is wrong.

Webpack (webpack.config.js):

module.exports = {
  output: {
    publicPath: '/', // Must match where your files are actually served
  },
};

Common mistake: setting publicPath: '' or a relative path when your app is served from a subdirectory. If your app lives at https://example.com/app/, set:

output: {
  publicPath: '/app/',
}

Create React App: Set the homepage field in package.json:

{
  "homepage": "/app/"
}

Vite (vite.config.js):

export default defineConfig({
  base: '/app/',
});

Open the browser DevTools Network tab when the error occurs. Look at the URL of the failing request. If it points to the wrong path, publicPath or base is the problem.

6. Clear the Service Worker Cache

If your app uses a service worker (common with Create React App’s serviceWorker.register() or a PWA setup), the service worker may cache old HTML and serve it even after deployment.

Force the service worker to update:

// In your service worker registration
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').then((registration) => {
    registration.addEventListener('updatefound', () => {
      const newWorker = registration.installing;
      newWorker.addEventListener('statechange', () => {
        if (newWorker.state === 'activated') {
          // New service worker active, reload to get new assets
          window.location.reload();
        }
      });
    });
  });
}

Nuclear option — unregister the service worker entirely:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.getRegistrations().then((registrations) => {
    for (const registration of registrations) {
      registration.unregister();
    }
  });
}

To manually clear for yourself: Open DevTools → Application → Service Workers → Unregister. Then Application → Cache Storage → delete all caches. Reload.

7. Keep Old Chunks During Deployment (Immutable Deployments)

The cleanest fix for the stale deployment problem is to not delete old chunks when you deploy. Keep at least two versions of chunks live at the same time.

How to do this:

  • Deploy to a new directory each time (e.g., /_next/static/BUILD_ID/) and keep the previous build’s directory. Next.js does this automatically.
  • On S3/CloudFront, upload new files without deleting old ones. Set a lifecycle rule to clean up files older than 24 hours.
  • On a traditional server, deploy to a new directory and symlink:
# Deploy
cp -r build/ /var/www/releases/$(date +%s)/
ln -sfn /var/www/releases/$(date +%s) /var/www/current

# Clean up old releases (keep last 3)
ls -dt /var/www/releases/*/ | tail -n +4 | xargs rm -rf

This eliminates the window where old HTML references deleted chunks.

8. Next.js-Specific Solutions

Next.js has its own chunk loading behavior that requires specific fixes. If you’re also seeing hydration errors alongside chunk failures, see Fix: Hydration failed because the initial UI does not match.

App Router — use loading.js for built-in Suspense boundaries:

Next.js App Router wraps each route in a Suspense boundary if you provide a loading.js file. This catches chunk load failures at the route level:

app/
  dashboard/
    page.js
    loading.js    ← Acts as Suspense fallback
    error.js      ← Catches chunk errors

Create an error.js that handles chunk failures:

'use client';

import { useEffect } from 'react';

export default function Error({ error, reset }) {
  useEffect(() => {
    if (
      error.message?.includes('Loading chunk') ||
      error.name === 'ChunkLoadError'
    ) {
      // Chunk load failure — reload to get new deployment
      window.location.reload();
    }
  }, [error]);

  return (
    <div>
      <h2>Something went wrong</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

Pages Router — use next/dynamic with error handling:

import dynamic from 'next/dynamic';

const Dashboard = dynamic(() => import('../components/Dashboard'), {
  loading: () => <p>Loading...</p>,
  ssr: false,
});

next/dynamic is Next.js’s wrapper around React.lazy. It supports SSR control and a loading state out of the box.

Check your assetPrefix and basePath: If you serve Next.js assets from a CDN, make sure assetPrefix in next.config.js points to the right URL:

// next.config.js
module.exports = {
  assetPrefix: 'https://cdn.example.com',
  // OR for subdirectory deployments:
  basePath: '/app',
};

If the chunk request URLs in DevTools don’t match where your files actually live, this is the problem. See module resolution issues for more on path misconfiguration.

9. Fix Ad Blockers and Browser Extensions Blocking Chunks

Some ad blockers and privacy extensions block JavaScript files that match certain patterns. If a chunk filename happens to contain strings like ad, analytics, track, or banner, it may be blocked.

How to confirm: Check the Network tab. If the chunk request shows ERR_BLOCKED_BY_CLIENT, an extension is blocking it.

Fix: Rename the chunk. In webpack, use chunkFilename with a custom naming pattern:

// webpack.config.js
output: {
  chunkFilename: 'static/js/[contenthash:8].js',
}

Or use webpack’s magic comments to name chunks explicitly:

const Dashboard = React.lazy(() =>
  import(/* webpackChunkName: "dash-view" */ './pages/Dashboard')
);

Avoid chunk names that contain words commonly blocked by ad filter lists.

Still Not Working?

Real-world scenario: A team deploys to S3 and CloudFront. After each deploy, some users see blank pages. The root cause: the CI pipeline uploads new assets and then invalidates the CloudFront cache for index.html, but the invalidation takes 5-10 seconds. During that window, CloudFront serves the old index.html that references chunk filenames from the previous build — which were already deleted from S3. The fix: upload new assets first without deleting old ones, invalidate index.html, and only delete old assets after 24 hours via an S3 lifecycle rule.

Check for partial deployments. If your CI/CD pipeline uploads files to S3 or a CDN, the upload might not be atomic. HTML may reference chunks that haven’t been uploaded yet. Fix this by uploading all assets first, then updating index.html (or the equivalent entry point) last.

Check for CORS issues on your CDN. If chunks are served from a different domain than your HTML (e.g., cdn.example.com vs app.example.com), you need CORS headers on the CDN:

Access-Control-Allow-Origin: https://app.example.com

Without this, the browser blocks the chunk request silently. See Fix: CORS Access-Control-Allow-Origin for details on configuring cross-origin headers.

Check for integrity hash mismatches. If you use Subresource Integrity (SRI), a CDN that modifies files (minification, compression changes) will cause integrity checks to fail. Either disable SRI or ensure your CDN serves files byte-for-byte identical to what you built.

Check your reverse proxy or load balancer. If you have multiple servers and only some received the new deployment, requests may hit a server that doesn’t have the new chunks. Ensure all servers are updated before routing traffic.

Your Vite build uses relative paths. If base is set to './' in vite.config.js, dynamic imports may resolve relative to the current page URL rather than the app root. This breaks when navigating to nested routes. Set base: '/' unless you specifically need relative paths.

webpack_require is loading chunks from the wrong origin. If you use micro-frontends or Module Federation, each remote needs its own publicPath configured correctly. A mismatch means one app requests chunks from another app’s origin, which doesn’t have them.

Your build isn’t generating content hashes. If your webpack config uses [name].js instead of [name].[contenthash].js for output filenames, browsers cache old chunk content under the same filename. New deployments serve new content, but the browser uses its cached version — which may reference imports that no longer exist in the new build. Always use content hashes:

output: {
  filename: '[name].[contenthash:8].js',
  chunkFilename: '[name].[contenthash:8].chunk.js',
}

React Router or Next.js prefetching fails silently. Both frameworks prefetch chunks for linked routes. If prefetching fails (user went offline, VPN dropped), the chunk isn’t cached, and navigation fails later. Combine prefetch with the retry strategy from Fix 2 to handle this.

Mobile Safari aggressively drops import() promises in background tabs. When a tab is suspended on iOS Safari, in-flight chunk requests can be cancelled and the rejected promise lands on the next foreground frame as an unhandled ChunkLoadError. The retry pattern in Fix 2 covers this, but make sure the error event listener in Fix 1 is registered before any lazy boundary mounts — listeners added after the failure fires will miss it. If you ship a PWA, also handle the pageshow event with persisted === true and trigger a soft refresh when chunks failed during the suspended period.

Sentry or your error tracker masks the original URL. Some error trackers replace error.message with their own normalized text by the time your global handler sees it. If you only match on the literal string Loading chunk and failed, you can miss real chunk errors that were rewritten to “Script error.” Also check error.name === 'ChunkLoadError' and event.filename against your chunk URL prefix to catch the cross-origin “Script error” case browsers emit when CORS is missing on the chunk response.

Edge cache and origin disagree mid-deploy. With multi-region CDNs (Cloudflare, Fastly, CloudFront) different POPs can have different cache states for a few minutes after a deploy. A user on POP-A gets new HTML, prefetches new chunks, and the request happens to route through POP-B which still has stale chunk URLs in its negative cache as 404. Force a purge across all regions on deploy, not just one, and prefer Cache-Control: s-maxage=0 on HTML over relying on manual invalidations.

If you’re seeing import resolution errors at build time rather than chunk loading errors at runtime, the problem is different — your bundler can’t find the module during compilation, not during dynamic loading in the browser.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles