Skip to content

Fix: React.lazy and Suspense Errors (Element Type Invalid, Loading Chunk Failed)

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix React.lazy and Suspense errors — Element type is invalid, A React component suspended while rendering, Loading chunk failed, and lazy import mistakes with named vs default exports.

The Error

You use React.lazy() for code splitting and get one of these errors:

Error: Element type is invalid. Expected a string (for built-in components) or a class/function
(for composite components) but got: object.

Or:

A React component suspended while rendering, but no fallback UI was specified.
Add a <Suspense fallback=...> component higher in the tree to provide a loading indicator
or placeholder to display.

Or at runtime when a user navigates:

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

Or in the console:

Warning: ReactDOM.render is no longer supported in React 18.

Why This Happens

React.lazy() enables dynamic imports for code splitting — the component bundle is loaded on demand instead of being included in the main bundle. Under the hood, React.lazy returns a special component that throws a Promise on first render. React’s Suspense mechanism catches that Promise, shows the fallback, and re-renders when the Promise resolves. If anything breaks this chain — wrong export shape, missing Suspense boundary, failed network fetch — the component cannot render and you see one of the errors above.

The “Element type is invalid” error is almost always the export-shape mismatch. React.lazy calls the module’s default export to get the component. A file with only named exports has no default, so React.lazy receives undefined and React fails when it tries to render that as a component. The error message is generic because React does not know the import came from lazy.

The “Loading chunk failed” error is a deployment-pattern issue. Webpack and Vite hash chunk filenames so caches are invalidated on each build. After deploying, old index.html references new chunk names. But if a user loaded index.html before your deploy and navigates to a lazy route after, the browser requests the old chunk URL, which no longer exists. The error surfaces only on returning visitors during a deploy window.

Common root causes:

  • Named export instead of default exportReact.lazy() only works with modules that have a default export.
  • Missing <Suspense> wrapper — any lazily loaded component must be wrapped in <Suspense>.
  • <Suspense> in the wrong place — the Suspense boundary must be an ancestor of the lazy component, not the lazy component itself.
  • Chunk loading failures — the JS chunk file could not be fetched (network error, 404, stale cached file after a new deployment).
  • Server-side rendering (SSR) without a compatible setupReact.lazy() does not work with SSR out of the box (use React.lazy with Suspense on the client side only, or use a framework like Next.js).
  • Circular imports — dynamic imports in circular dependency chains can produce undefined modules.

Platform and Environment Differences

React.lazy is a client-side primitive. How it behaves — and whether you should use it at all — depends heavily on your framework, bundler, and runtime environment.

Next.js App Router (React Server Components). In an App Router project, server components do not use React.lazy because they are streamed from the server, not lazy-loaded in the browser. For client components, prefer next/dynamic instead of React.lazy — it integrates with Next’s SSR and streaming. next/dynamic with { ssr: false } is the right pattern for components that must skip server rendering (browser-only libraries, window-dependent code). For hydration mismatches that surface around lazy boundaries, see Fix: Next.js hydration failed.

Next.js Pages Router. React.lazy works for purely client-side lazy loading, but the recommended path is next/dynamic. Pages Router pre-renders pages at build or request time, so React.lazy inside a page component runs only after hydration.

Remix and React Router. Remix’s route module system already lazy-loads route code per navigation — using React.lazy for routes is redundant. For component-level lazy loading inside a route, React.lazy works normally. React Router 6.4+ supports lazy() at the route level via the data router, which is preferred over React.lazy for routes.

Vite vs Webpack code splitting. Vite uses Rollup for production builds; Webpack uses its own splitter. Both support import() natively, but tuning differs: Vite’s build.rollupOptions.output.manualChunks controls chunk grouping, while Webpack uses splitChunks and magic comments (/* webpackChunkName: "dashboard" */). Magic comments are a Webpack feature and have no effect in Vite. For Vite chunk-size warnings, see Fix: Vite build chunk size warning. For Webpack-specific bundle issues, see Fix: Webpack bundle too large.

React Native and Suspense. React Native supports Suspense for data fetching but has historically had limited support for React.lazy due to Metro’s bundling model — Metro bundles all JS into a single file by default. RAM bundles and on-demand modules are possible but require setup. Most React Native apps use code splitting at the navigation level via React Navigation’s lazy screens rather than React.lazy.

Electron renderer process. In Electron, the renderer is Chromium and React.lazy works the same as in a browser. The catch is asset paths: lazy chunks are requested relative to the page’s URL, which is file:// in packaged builds. If your bundler outputs paths assuming /, the request fails. Configure the bundler’s publicPath (Webpack) or base (Vite) to ./ for Electron packaged builds. For broader Electron issues, see Fix: Electron not working.

Browser network throttling. Slow connections (3G, throttled DevTools) make chunk loads visibly slow, which surfaces Suspense fallback bugs and double-renders. Always test code-split boundaries with the Network tab set to “Slow 3G” — fallbacks should look intentional, not flicker.

Service worker caching. A service worker that caches the JS bundles can serve stale chunks after a deploy, producing ChunkLoadError. Configure the worker’s strategy as NetworkFirst for index.html and CacheFirst only for files with content hashes in their names.

Fix 1: Use Default Exports with React.lazy

React.lazy() requires the imported module to have a default export that is a React component. Named exports are not supported directly.

Broken — named export:

// components/UserProfile.jsx
export function UserProfile() {   // Named export
  return <div>Profile</div>;
}

// App.jsx
const UserProfile = React.lazy(() => import("./components/UserProfile"));
// Error: Element type is invalid — the module exports {UserProfile}, not a default

Fixed — add a default export:

// components/UserProfile.jsx
export function UserProfile() {
  return <div>Profile</div>;
}

export default UserProfile;  // Add default export

Alternative — re-export the named export as default in the import:

// App.jsx — wrap named export to make React.lazy work
const UserProfile = React.lazy(() =>
  import("./components/UserProfile").then(module => ({
    default: module.UserProfile,  // Extract named export as default
  }))
);

This pattern is useful when you cannot modify the component file (e.g., a third-party library).

Pro Tip: Make it a convention to always add export default to component files, even when they also have named exports. It removes all ambiguity with React.lazy() and simplifies imports throughout the codebase.

Fix 2: Wrap Lazy Components in Suspense

Every React.lazy() component must be wrapped in a <Suspense> boundary. Without it, React throws an error when the component suspends during loading.

Broken — no Suspense:

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

function App() {
  return (
    <div>
      <Dashboard />  {/* Error: No Suspense boundary found */}
    </div>
  );
}

Fixed — wrap in Suspense:

import React, { Suspense, lazy } from "react";

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

function App() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <Dashboard />
      </Suspense>
    </div>
  );
}

The fallback prop accepts any React element — a spinner, skeleton screen, or simple text. It renders while the lazy component’s bundle is being fetched.

Suspense can wrap multiple lazy components:

const Dashboard = lazy(() => import("./Dashboard"));
const Settings = lazy(() => import("./Settings"));
const Profile = lazy(() => import("./Profile"));

function App() {
  return (
    <Suspense fallback={<PageSpinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/profile" element={<Profile />} />
      </Routes>
    </Suspense>
  );
}

One <Suspense> wrapping all routes is a common and valid pattern — it shows the spinner whenever any route is loading.

Fix 3: Place Suspense at the Right Level

The <Suspense> boundary must be an ancestor of the component that suspends — not the component itself, and not a sibling.

Broken — Suspense inside the lazy component:

// Dashboard.jsx — lazy loaded
function Dashboard() {
  return (
    <Suspense fallback={<div>Loading chart...</div>}>
      <ExpensiveChart />
    </Suspense>
  );
}

// App.jsx — no Suspense wrapping Dashboard
const Dashboard = lazy(() => import("./Dashboard"));

function App() {
  return <Dashboard />;  // Dashboard itself suspends during load — no boundary above it
}

Fixed — Suspense in the parent:

function App() {
  return (
    <Suspense fallback={<div>Loading dashboard...</div>}>
      <Dashboard />  {/* Dashboard can now suspend safely */}
    </Suspense>
  );
}

The inner <Suspense> inside Dashboard is valid for nested lazy loading — but the outer one in App is required for Dashboard itself to load.

Fix 4: Fix ChunkLoadError After Deployment

ChunkLoadError: Loading chunk X failed happens when:

  1. A user loads your app (gets index.html pointing to old chunk filenames).
  2. You deploy a new version (chunk filenames change due to content hashing).
  3. The user navigates to a lazy-loaded route — the browser requests the old chunk URL, which no longer exists (404).

Fix — catch ChunkLoadError and reload:

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

  static getDerivedStateFromError(error) {
    if (error.name === "ChunkLoadError") {
      return { hasError: true };
    }
    return null;
  }

  componentDidCatch(error) {
    if (error.name === "ChunkLoadError") {
      // Reload the page to get the latest chunks
      window.location.reload();
    }
  }

  render() {
    if (this.state.hasError) {
      return <div>Updating... please wait.</div>;
    }
    return this.props.children;
  }
}

Wrap your lazy routes:

function App() {
  return (
    <ChunkErrorBoundary>
      <Suspense fallback={<PageSpinner />}>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </ChunkErrorBoundary>
  );
}

Alternative — handle in the lazy import:

const Dashboard = lazy(() =>
  import("./Dashboard").catch(() => {
    window.location.reload();
    return new Promise(() => {}); // Never resolves — page reloads instead
  })
);

Server-side fix: Configure your server to never cache index.html but cache chunk files aggressively:

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

# Cache chunks forever (they have content hashes in filenames)
location /static/ {
  add_header Cache-Control "public, max-age=31536000, immutable";
}

Fix 5: Add an Error Boundary for Lazy Loading Failures

<Suspense> handles the loading state, but if the import fails (network error, module not found), you need an Error Boundary to catch the error:

import React from "react";

class LazyErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { error: null };
  }

  static getDerivedStateFromError(error) {
    return { error };
  }

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

// Usage
function App() {
  return (
    <LazyErrorBoundary>
      <Suspense fallback={<div>Loading...</div>}>
        <Dashboard />
      </Suspense>
    </LazyErrorBoundary>
  );
}

With react-error-boundary library (recommended):

import { ErrorBoundary } from "react-error-boundary";

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div>
      <p>Something went wrong: {error.message}</p>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Suspense fallback={<div>Loading...</div>}>
        <Dashboard />
      </Suspense>
    </ErrorBoundary>
  );
}

Fix 6: Fix React.lazy with SSR

React.lazy() is a client-only feature. Using it in a server-side rendered app (Next.js, Remix, custom SSR) without proper handling causes errors:

Next.js — use dynamic import instead:

import dynamic from "next/dynamic";

// Next.js's dynamic() is the SSR-compatible equivalent of React.lazy
const Dashboard = dynamic(() => import("./Dashboard"), {
  loading: () => <div>Loading...</div>,
  ssr: false,  // Disable SSR for this component if needed
});

Generic SSR — skip lazy on server:

const Dashboard = typeof window !== "undefined"
  ? React.lazy(() => import("./Dashboard"))
  : () => null;  // Render nothing on server

This is a workaround — prefer using a framework that handles SSR + lazy loading properly.

Fix 7: Lazy Load at the Route Level

The most impactful use of React.lazy is route-based code splitting — each route loads its own bundle only when navigated to:

import React, { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";

const Home = lazy(() => import("./pages/Home"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const AdminPanel = lazy(() => import("./pages/AdminPanel"));

function App() {
  return (
    <Suspense fallback={<div className="page-loader">Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/admin" element={<AdminPanel />} />
      </Routes>
    </Suspense>
  );
}

This pattern keeps the initial bundle small — users only download code for pages they actually visit.

Verify bundle splitting worked by checking your build output:

npm run build
# Look for multiple chunk files:
# dist/static/js/main.abc123.js       (main bundle)
# dist/static/js/Dashboard.def456.js  (Dashboard chunk)
# dist/static/js/Settings.ghi789.js   (Settings chunk)

If all code is in one file, the lazy imports are not splitting correctly — check your bundler (Webpack/Vite) configuration.

Still Not Working?

Check for React version. React.lazy requires React 16.6+. Suspense for data fetching (not just lazy loading) requires React 18+. Run npm list react to check your version.

Check for Vite-specific issues. Vite supports dynamic imports natively. If chunks are not being created, check vite.config.js for build.rollupOptions.output.manualChunks settings that may be overriding the automatic splitting.

Check for import path typos. A typo in the import path causes the lazy import to fail at runtime with a module not found error. Verify the path resolves correctly by checking if the static import version works.

Check for fast refresh / HMR conflicts. In development, hot module replacement can sometimes cause issues with lazy components. Try a full page refresh (Ctrl+Shift+R) to rule out stale module state.

Check that React.lazy is called outside the render function. Defining const Lazy = React.lazy(...) inside a component body creates a new lazy component on every render, which throws “A component suspended while responding to synchronous input” or causes infinite reloads. Always define lazy components at the module top level.

Check StrictMode double-mounting effects. In development with <StrictMode>, React 18+ intentionally mounts and unmounts components twice to surface effect-cleanup bugs. Lazy components that have side effects in render (such as data fetches without proper Suspense) can appear to load twice. This does not happen in production builds.

Check that the dynamic import path is statically analyzable. Bundlers split on import(...) only when the argument is a literal string or a template the bundler can resolve. A fully dynamic path like import("./components/" + name) may fall back to a single bundle instead of producing separate chunks. Use explicit imports per lazy component to guarantee splitting.

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