Handling Lazy Chunk Load Failures and Fallbacks

Every dynamic import is a network request that can fail, and unlike the initial bundle β€” which the browser retries as part of normal navigation β€” a failed import() produces a rejected promise that the application has to do something with. The default behaviour of most applications is to do nothing: the promise rejects, no boundary catches it, and the user sees a permanently empty region, a spinner that never resolves, or a blank route.

The failure rate is not negligible. On real mobile traffic, individual asset requests fail at rates between 0.3% and 2% depending on network quality β€” and every one of those failures lands on a user who has already interacted, having clicked a link or opened a panel. A 1% chunk failure rate on a route that 50,000 sessions a day reach is 500 broken interactions a day, none of which appear in build-time metrics.

This page covers the resilience layer that sits between dynamic import patterns for on-demand loading and the user: how to classify a chunk failure, when to retry, when to reload, what to render when neither works, and how to know it is happening.

Three failure classes, three responses

Treating every chunk failure the same way is the root of most bad recovery behaviour. There are three distinct classes, and they are distinguishable at runtime.

Transient network failure. The file exists and the manifest is current, but the request failed β€” a radio handover, a dropped packet, a proxy hiccup, a captive portal intercept. The correct response is a retry after a short delay. Reloading the page here is destructive: it discards unsaved form state to fix a problem a 400 ms retry would have solved.

Stale manifest. The running page’s chunk manifest references filenames that no longer exist on the origin, because a deploy replaced them. Retrying is futile β€” every attempt hits the same 404. The correct response is a reload, which fetches a current document and a current manifest. This is the case analysed in detail in fixing ChunkLoadError after a new deploy.

Evaluation error. The chunk downloaded successfully but threw while executing β€” a genuine bug, a missing global, an incompatible browser feature. Retrying re-executes the same broken module; reloading produces an infinite loop. The correct response is to render a degraded state and report the error.

Classifying a Failed Dynamic Import A rejected import is classified by whether the file was reachable and whether it executed: unreachable and current means retry, unreachable and stale means reload, executed and threw means degrade and report. import() rejected capture error + chunk name Did the file execute? Evaluation error degrade + report, never retry yes Build version still current? no Transient failure retry twice with backoff yes Stale manifest reload to a current document no

Distinguishing the first two classes needs one piece of infrastructure: a build version the running page can compare against the deployed one. Everything else follows from the error object β€” an evaluation error carries a stack inside the chunk’s own source, while a fetch failure does not.

A single loader as the policy surface

Scattering retry logic across dozens of import() call sites guarantees inconsistency. Route every dynamic import through one loader, and the retry policy, the classification logic, and the telemetry live in exactly one place.

One Loader, One Policy Every dynamic import in the application passes through a single helper, so classification, retry limits and reporting are defined once instead of per call site. route import widget import locale import loadChunk() classify Β· retry Β· report one behaviour consistent across the app Scattered retry logic guarantees inconsistency; one surface guarantees the opposite
// lazy-loader.js β€” single policy surface for every dynamic import
// Works with Webpack 5 and Vite 5+; both reject the import promise on failure.
const CHUNK_FAILURE = /ChunkLoadError|Loading chunk|Failed to fetch dynamically imported module|error loading dynamically imported module/i;

export function loadChunk(factory, name, attempt = 0) {
  return factory().catch(async (error) => {
    const isFetchFailure = CHUNK_FAILURE.test(String(error && error.message));

    // An evaluation error inside the chunk: retrying re-runs the same bug.
    if (!isFetchFailure) {
      report('chunk_eval_error', { name, message: String(error.message) });
      throw error;
    }

    // A stale manifest cannot be fixed by retrying β€” escalate to a reload.
    if (await buildIsStale()) {
      report('chunk_stale_manifest', { name, attempt });
      window.location.reload();
      return new Promise(() => {});   // suspend; a reload is already in flight
    }

    if (attempt >= 2) {
      report('chunk_load_failed', { name, attempt });
      throw error;                     // let the boundary render a degraded state
    }

    const backoff = 300 * Math.pow(2, attempt);   // 300 ms, then 600 ms
    await new Promise((resolve) => setTimeout(resolve, backoff));
    return loadChunk(factory, name, attempt + 1);
  });
}

The buildIsStale() check is a single conditional request against a small version endpoint served with Cache-Control: no-store. It has to be cheap, and it has to be resistant to its own failure: if the version check itself cannot complete, treat the build as current and fall through to the retry path rather than reloading on a guess.

// vite.config.js β€” Vite 5+: expose the build id the runtime compares against
import { defineConfig } from 'vite';

export default defineConfig({
  define: {
    // Baked into the bundle at build time; compared against /version.json at runtime.
    __BUILD_ID__: JSON.stringify(process.env.BUILD_ID || 'dev'),
  },
});
// webpack.config.js β€” Webpack 5: the same value, plus a failure-tolerant chunk load timeout
const webpack = require('webpack');

module.exports = {
  output: {
    // Give a slow mobile connection room before the runtime rejects the request.
    chunkLoadTimeout: 30000,
  },
  plugins: [
    new webpack.DefinePlugin({
      __BUILD_ID__: JSON.stringify(process.env.BUILD_ID || 'dev'),
    }),
  ],
};

Webpack’s chunkLoadTimeout defaults to 120 seconds, which is far longer than any user will wait but short enough that a hung request eventually rejects rather than hanging forever. Lowering it to 30 seconds converts a dead request into a catchable failure while there is still a user present to see the recovery.

Framework integration: boundaries around lazy regions

A loader that rejects cleanly is only half the design; something has to render when it does. In React, that is an error boundary placed around each lazy region β€” not one boundary around the whole application, which converts a failed widget into a blank page.

// LazyRegion.jsx β€” React 18+: retryable boundary scoped to one lazy region
import { Component, Suspense } from 'react';

class ChunkBoundary extends Component {
  state = { failed: false };

  static getDerivedStateFromError() {
    return { failed: true };
  }

  retry = () => {
    // Remount the subtree: React.lazy re-invokes the loader after a failure.
    this.setState({ failed: false });
  };

  render() {
    if (this.state.failed) return this.props.fallback(this.retry);
    return this.props.children;
  }
}

export function LazyRegion({ children, skeleton, degraded }) {
  return (
    <ChunkBoundary fallback={degraded}>
      <Suspense fallback={skeleton}>{children}</Suspense>
    </ChunkBoundary>
  );
}

Vue 3 folds the same responsibility into defineAsyncComponent, which accepts both an error component and an explicit retry hook:

// Vue 3.4+ β€” errorComponent renders in place; onError decides whether to retry
import { defineAsyncComponent } from 'vue';

const ReportBuilder = defineAsyncComponent({
  loader: () => import('./ReportBuilder.vue'),
  loadingComponent: ReportSkeleton,
  errorComponent: ReportUnavailable,
  delay: 200,
  timeout: 20000,
  onError(error, retry, fail, attempts) {
    // Mirror the loader policy: two retries, then surface the error component.
    if (attempts <= 2 && /fetch dynamically imported module/i.test(error.message)) retry();
    else fail();
  },
});

Note the asymmetry worth planning for: an import() called directly from an event handler β€” the pattern used for interaction-triggered component splitting β€” never passes through a render boundary at all. Its rejection has to be caught at the call site and turned into component state, or it becomes an unhandled promise rejection that no boundary will ever see.

Quantified impact

  • Recovered interactions: 70–85% of chunk failures. Two retries with backoff resolve the large majority of failures on mobile networks, because most are transient rather than structural.
  • Failure-to-feedback latency: ≀ 1.2 s. With 300 ms and 600 ms backoff plus request time, a user waiting on a genuinely unavailable chunk sees an honest degraded state inside roughly a second, instead of an indefinite spinner.
  • Blast radius: one region instead of one route. Per-region boundaries keep a failed widget from blanking a route that is otherwise fully interactive, which typically converts a session-ending error into a cosmetic one.
  • Reload loops eliminated. Gating reloads behind an explicit staleness check β€” rather than reloading on any chunk error β€” removes the class of incident where a broken chunk reloads the page indefinitely.
  • Detection time: minutes, not days. Reporting failures with the chunk name and build id makes a deploy-induced spike visible in monitoring immediately, rather than surfacing through support tickets.

Common pitfalls

Retrying a stale manifest. Retrying a URL that returns 404 three times simply adds 900 ms to a guaranteed failure. Classify before retrying; the version check exists precisely to make this distinction cheap.

Reloading on evaluation errors. If the chunk downloads and throws, a reload re-downloads and re-throws. Teams that wire β€œany chunk error β†’ reload” discover this the first time a bad chunk ships, when a subset of users end up in a reload loop that only clearing site data escapes.

One global boundary. A single top-level error boundary means any lazy failure anywhere replaces the entire application with an error screen. Scope boundaries to the smallest region that can degrade independently.

Fallbacks that shift layout. A degraded state with different dimensions than the loading skeleton produces a second layout shift on top of the failure, compounding a functional problem with a visual one. Skeleton, loaded component, and error state should all occupy the same box.

Silent catch blocks. catch(() => {}) around a dynamic import makes the console clean and the failure invisible. Every catch must report, even when the visible response is to hide the feature.

Unbounded retries. A retry loop with no attempt ceiling turns a CDN incident into a self-inflicted request storm from every open tab, exactly when the origin is least able to absorb it.

Retry Timeline With Exponential Backoff Three load attempts separated by 300 and 600 millisecond backoff intervals; the third attempt succeeds roughly 1.2 seconds after the first, still inside the window where a degraded state has not yet been shown. Attempt sequence for one lazy chunk attempt 1 β€” fail wait 300 ms attempt 2 β€” fail wait 600 ms attempt 3 β€” resolved What the region shows meanwhile skeleton at the loaded component's exact dimensions β€” no shift, no spinner flash component renders 0 ms 600 ms 1200 ms Ceiling of two retries keeps the worst case inside the window a user will still wait through

Verification workflow

  1. Simulate a transient failure. In DevTools, block the chunk’s URL pattern for a single request using request blocking, trigger the lazy region, then unblock. Confirm the retry succeeds and no reload occurs.

  2. Simulate a stale manifest. Build, serve, load the page, then rebuild with a changed BUILD_ID and delete the old chunk from the served directory. Trigger the region and confirm the version check fires and the page reloads exactly once.

  3. Simulate an evaluation error. Temporarily throw at the top level of a lazily-imported module. Confirm the boundary renders the degraded state, that no retry is attempted, and that no reload loop starts.

  4. Confirm boundary scope. With a failure injected into one widget, verify the rest of the route still responds to input β€” navigation, forms, and unrelated widgets should all be unaffected.

  5. Confirm telemetry. Check that each of the three simulations produced a distinct event with the chunk name, attempt count, and build id attached. If all three report identically, the classification logic is not actually branching.

  6. Confirm layout stability across states. Record a performance trace through skeleton β†’ error β†’ retry β†’ loaded and confirm no layout-shift entries are attributed to the region, as covered in the sibling page on designing Suspense fallbacks that avoid layout shift.

FAQ

Should a failed chunk always trigger a page reload?

No. A reload is the correct last resort for a stale-manifest failure, where the running page references files that no longer exist, because only a reload can pick up a current manifest. It is the wrong response to a transient network error, where a retry succeeds without losing the user’s unsaved state, and it is actively harmful for an evaluation error inside the chunk, where the same error will recur immediately after the reload and produce a loop. Classify the failure before deciding.

How many retries should a dynamic import get?

Two retries with exponential backoff covers the overwhelming majority of recoverable failures without making a genuine outage feel slow. The first retry absorbs a dropped packet or a momentary radio handover; the second covers a slower edge failover. Beyond that, additional attempts mostly add latency to a failure that is not going to resolve, and they delay the point at which the user sees an honest error state or the app escalates to a reload.

Does an error boundary catch a dynamic import failure?

In React it does, but only for imports that are wired through lazy() and rendered inside the boundary β€” the rejected promise is surfaced during render, which the boundary can catch. A bare import() called from an event handler rejects outside the render cycle, so no boundary will see it and the rejection becomes an unhandled promise rejection instead. Event-handler imports need an explicit catch block that sets error state the component renders.

What should the degraded state show when a chunk never loads?

Whatever keeps the surrounding page usable, plus a way to try again. For a non-essential widget that usually means hiding the region entirely and logging the failure, because an error card for a chart the user did not ask for is noise. For a primary feature it means an inline message scoped to that region with a retry button, never a full-page error screen β€” the rest of the route still works, and blanking it converts one broken widget into a broken session.