Lazy loading and Suspense

Published:

An ordinary import gives the renderer a component before the application starts:

import Settings from './Settings.js';

function App() {
  return <Settings />;
}

That works. It also puts Settings.js in the application's initial module graph, even if most visitors never open settings.

Suppose settings is large and rarely used. We want its code to load only when the user asks for it.

That leaves the next hole:

if a component's code is not available yet, how can the renderer show a deliberate fallback until it arrives?

The value we cannot render #

JavaScript's dynamic import() starts loading a module when the expression is evaluated. Move the import behind the moment it is needed:

function openSettings() {
  const Settings = import('./Settings.js');
  root.render(<Settings />);
}

The name is capitalized, but its value is not a component type. Reproduce the shape of the attempt with a Promise whose eventual result looks like a module:

function SettingsPanel() {
  return React.createElement('p', null, 'Settings ready');
}

const Settings = Promise.resolve({ default: SettingsPanel });

root.render(React.createElement(Settings));

Waiting to run

Not run yet.

React rejects the element type. Settings is a Promise object, not a function or another valid component type.

That tells us what import() actually contributed. It immediately returns a Promise. If loading succeeds, that Promise later fulfills with a module namespace object containing the module's exports.[3]

import('./Settings.js') -> Promise -> module object -> module.default

Bundlers can use the dynamic-import expression to put Settings.js and the modules reachable from it in a separate chunk. The browser can request that chunk when the expression runs. The exact output belongs to the build tool; the Webpack code-splitting develops that side of the boundary.[4]

The first attempt exposes the constraint:

deferred code needs a component type that React can attempt to render before the code is ready

Waiting outside React #

Perhaps we can wait for the Promise and render its default export ourselves:

import('./Settings.js').then((module) => {
  const Settings = module.default;
  root.render(<Settings />);
});

This eventually renders settings, but it gives React no description while the import is pending. We can add an imperative render before the request:

root.render(<p>Loading settings...</p>);

import('./Settings.js').then((module) => {
  root.render(React.createElement(module.default));
});

The visible result is possible, but module loading now controls the root from outside the component tree. A real component would need to coordinate the request, imported type, loading state, and rerender. Cancellation and failures would add more branches.

The renderer already knows how to retry work that cannot finish. What it lacks is a component type that can participate in rendering before its implementation is ready.

That narrows the hole:

how can an asynchronous module behave like a component type during render?

A component type backed by a loader #

lazy accepts a loader and returns that missing component type:[1]

import { lazy } from 'react';

const Settings = lazy(() => import('./Settings.js'));

Declaring Settings does not run the loader. React calls it the first time rendering reaches <Settings />.[1]

For the first attempt, let Settings.js provide a default-exported component:

// Settings.js
export default function Settings() {
  return <p>Settings ready</p>;
}

While the Promise is pending, the lazy component cannot produce its description. React treats that render attempt as suspended.[1] The type hole is filled, but the tree still contains no answer for the time before Settings is ready.

That exposes the next hole:

what may React commit while a child cannot finish rendering?

A boundary for unavailable content #

The smallest boundary can render nothing while its child is unavailable:

<Suspense fallback={null}>
  <Settings />
</Suspense>

This contains the suspended render, but null deliberately leaves a blank region. The component tree needs an alternate description for that interval.

Suspense accepts that description through its fallback prop:[2]

<Suspense fallback={<p>Loading settings...</p>}>
  <Settings />
</Suspense>

Test the new boundary with a delayed loader:

const { lazy, Suspense } = React;

let releaseSettings;

const Settings = lazy(() => new Promise((resolve) => {
  releaseSettings = () => {
    resolve({ default: () => <p>Settings ready</p> });
  };
}));

root.render(
  <section>
    <h1>Account</h1>
    <Suspense fallback={<p>Loading settings...</p>}>
      <Settings />
    </Suspense>
  </section>
);

function whenTextIncludes(text, run) {
  if (mountNode.textContent.includes(text)) {
    run();
    return;
  }

  setTimeout(() => whenTextIncludes(text, run), 5);
}

whenTextIncludes('Loading settings...', () => {
  console.log('first commit:', mountNode.textContent);
  releaseSettings();

  whenTextIncludes('Settings ready', () => {
    console.log('second commit:', mountNode.textContent);
  });
});

Waiting to run

Not run yet.

The fallback is an ordinary React node. The experiment produces this sequence:

  1. rendering reaches Settings, starts its loader, and suspends
  2. the closest parent Suspense boundary renders its fallback
  3. React commits Account with Loading settings...
  4. the loader resolves to the module containing the component
  5. React retries the suspended content and commits Account with Settings ready

The fallback is a description React can render and commit now. The loaded child is a later description that replaces it through the same render-and-commit process we have seen before.

This fills the timing hole:

a Suspense boundary makes temporary unavailability part of the rendered tree

Without an appropriate boundary above the lazy component, there is no local fallback for React to show. lazy and Suspense solve different halves of the problem:

lazy(loader)             code availability -> component type
<Suspense fallback={…}>  suspended child   -> temporary UI boundary

The boundary decides what disappears #

A boundary replaces all of its children with its fallback when any child suspends.

Start with one boundary around the whole dashboard:

const { lazy, Suspense } = React;

let releaseReports;

const Reports = lazy(() => new Promise((resolve) => {
  releaseReports = () => {
    resolve({
      default: function Reports() {
        return <p>Three reports</p>;
      },
    });
  };
}));

function App() {
  return (
    <Suspense fallback={<p>Loading dashboard...</p>}>
      <section>
        <h1>Dashboard</h1>
        <Reports />
      </section>
    </Suspense>
  );
}

root.render(<App />);

function whenTextIncludes(text, run) {
  if (mountNode.textContent.includes(text)) {
    run();
    return;
  }

  setTimeout(() => whenTextIncludes(text, run), 5);
}

whenTextIncludes('Loading dashboard...', () => {
  console.log('first commit:', mountNode.textContent);
  releaseReports();

  whenTextIncludes('Three reports', () => {
    console.log('second commit:', mountNode.textContent);
  });
});

Waiting to run

Not run yet.

The first commit contains only Loading dashboard.... The reports suspended, but the boundary also hid the heading even though it was ready.

The rendered result is valid, but the boundary is wider than the uncertainty. Move the stable heading outside and wrap only the reports:

<section>
  <h1>Dashboard</h1>
  <Suspense fallback={<p>Loading reports...</p>}>
    <Reports />
  </Suspense>
</section>

Now both commits preserve Dashboard, while the uncertain region changes from its fallback to the reports.

The tree placement gives us a structural constraint:

one Suspense boundary groups the content that should be revealed as one unit

The fallback should match that unit. A small inline placeholder fits a deferred panel; a page skeleton may fit a page-sized boundary. Wrapping every lazy component independently is not automatically better, because it can produce a scattered loading sequence the design never intended.

Independent reveal points #

Add two deferred regions beneath one boundary. The summary becomes ready first, so perhaps React can reveal it while the chart continues loading:

const { lazy, Suspense } = React;

function deferredComponent(text, registerRelease) {
  return lazy(() => new Promise((resolve) => {
    registerRelease(() => {
      resolve({ default: () => <p>{text}</p> });
    });
  }));
}

let releaseSummary;
let releaseChart;

const Summary = deferredComponent('Summary ready', (release) => {
  releaseSummary = release;
});
const Chart = deferredComponent('Chart ready', (release) => {
  releaseChart = release;
});

function App() {
  return (
    <section>
      <h1>Analytics</h1>
      <Suspense fallback={<p>Loading analytics...</p>}>
        <Summary />
        <Chart />
      </Suspense>
    </section>
  );
}

root.render(<App />);

function whenTextIncludes(text, run) {
  if (mountNode.textContent.includes(text)) {
    run();
    return;
  }

  setTimeout(() => whenTextIncludes(text, run), 5);
}

function whenChartStarts(run) {
  if (releaseChart) {
    run();
    return;
  }

  setTimeout(() => whenChartStarts(run), 5);
}

whenTextIncludes('Loading analytics...', () => {
  console.log('first commit:', mountNode.textContent);
  releaseSummary();

  whenChartStarts(() => {
    console.log('after summary resolves:', mountNode.textContent);
    releaseChart();

    whenTextIncludes('Chart ready', () => {
      console.log('after chart resolves:', mountNode.textContent);
    });
  });
});

Waiting to run

Not run yet.

The output disproves the prediction. At the middle observation, Summary has resolved, but React still shows Loading analytics.... One child inside the boundary remains suspended, so the boundary keeps representing the pair as unavailable.

If the two regions should reveal independently, the tree needs two boundaries:

<Suspense fallback={<p>Loading summary...</p>}>
  <Summary />
</Suspense>
<Suspense fallback={<p>Loading chart...</p>}>
  <Chart />
</Suspense>

Now the summary can replace its fallback while the chart keeps its own. Nesting boundaries can express a larger initial loading region followed by progressively revealed inner regions.[2]

The failed shared-boundary experiment gives us the next constraint:

content that should reveal independently needs an independent Suspense boundary

Suspense boundaries therefore describe visible loading order in the component tree. They do not decide how the code is split; import() and the build tool do that. A chunk boundary and a Suspense boundary often meet at the same component, but they answer different questions:

  • the chunk boundary asks which code can load separately
  • the Suspense boundary asks which UI should wait and reveal together

The type that does not survive #

Rendering happens repeatedly. Perhaps the lazy declaration can stay beside the place where it is used:

const { lazy, useEffect, useState } = React;

const loadHelp = () => import('./Help.js');
let previousHelp = null;
let renderCount = 0;

function App() {
  const [theme, setTheme] = useState('light');
  const Help = lazy(loadHelp);

  renderCount += 1;
  console.log(
    'render:', renderCount,
    'same lazy type:', previousHelp === Help,
  );
  previousHelp = Help;

  useEffect(() => {
    if (theme === 'light') {
      setTheme('dark');
    }
  }, [theme]);

  return <p>Theme: {theme}</p>;
}

root.render(<App />);

Waiting to run

Not run yet.

The first comparison is false because there is no previous type. The important result is the second false: changing the theme renders App again, and that call creates a different lazy component object.

The loader has not run because the example never renders <Help />. The identity problem happens earlier: calling lazy(loadHelp) constructs the component type. A new type at the same tree position can make React reset state below it.

The comparison gives us the next constraint:

a lazy declaration must keep the same component identity across renders

Move the declaration to module scope:

const Help = lazy(() => import('./Help.js'));

function App() {
  return <Help />;
}

Now every App render refers to the same Help type. Repeat the comparison with that placement:

const { lazy, useEffect, useState } = React;

const loadHelp = () => import('./Help.js');
const Help = lazy(loadHelp);
let previousHelp = null;
let renderCount = 0;

function App() {
  const [theme, setTheme] = useState('light');

  renderCount += 1;
  console.log(
    'render:', renderCount,
    'same lazy type:', previousHelp === Help,
  );
  previousHelp = Help;

  useEffect(() => {
    if (theme === 'light') {
      setTheme('dark');
    }
  }, [theme]);

  return <p>Theme: {theme}</p>;
}

root.render(<App />);

Waiting to run

Not run yet.

The second comparison is now true. Both renders reached the exact same Help component object. Once React renders that type, lazy also caches the Promise returned by its loader and the resolved module.[1]

The corrected placement fills both holes:[1]

declare a lazy component at module scope so later renders reuse the same type and cached loader result

Suspense boundary activation #

We already know that Effects run after commit. Perhaps wrapping effect-driven loading in Suspense lets the boundary discover that loading state:

const { Suspense, useEffect, useState } = React;

function Profile() {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    const timer = setTimeout(() => setReady(true), 25);
    return () => clearTimeout(timer);
  }, []);

  if (!ready) {
    return <p>Profile loading itself...</p>;
  }

  return <p>Profile ready</p>;
}

root.render(
  <Suspense fallback={<p>Boundary fallback</p>}>
    <Profile />
  </Suspense>
);

function whenTextIncludes(text, run) {
  if (mountNode.textContent.includes(text)) {
    run();
    return;
  }

  setTimeout(() => whenTextIncludes(text, run), 5);
}

whenTextIncludes('Profile loading itself...', () => {
  console.log('first commit:', mountNode.textContent);

  whenTextIncludes('Profile ready', () => {
    console.log('second commit:', mountNode.textContent);
  });
});

Waiting to run

Not run yet.

The boundary fallback never appears. Profile successfully returned Profile loading itself... during its first render, so nothing suspended. Its Effect later changed state and caused another render.

Suspense responds when a supported resource prevents the render beneath it from completing; a lazy component is one such resource.[2]

This makes the distinction from before precise:

  • an Effect synchronizes a committed component with an external system
  • a lazy component makes its code availability part of rendering
  • a Suspense boundary provides a renderable fallback when that subtree cannot finish

Wrapping arbitrary fetching code in Suspense does not integrate it with React's rendering protocol. Suspense-enabled data loading requires a compatible framework or resource API.[b]

The module contract #

One last module exports its component by name:

// Settings.js
export function Settings() {
  return <p>Settings ready</p>;
}

// App.js
const Settings = lazy(() => import('./Settings.js'));

Use the same module shape in a live render:

const { lazy, Suspense } = React;

function SettingsPanel() {
  return <p>Settings ready</p>;
}

const Settings = lazy(() => Promise.resolve({
  Settings: SettingsPanel,
}));

root.render(
  <Suspense fallback={<p>Loading settings...</p>}>
    <Settings />
  </Suspense>
);

Waiting to run

Not run yet.

The loader succeeds, but rendering fails. The fulfilled module object has module.Settings; lazy looks for module.default and finds no component type.

That failure exposes the loader's complete contract:

a lazy loader must fulfill with an object whose default property is a valid component type

The simplest fix is a default export:

// Settings.js
export default function Settings() {
  return <p>Settings ready</p>;
}

If the module must retain its named export, adapt the Promise to the required shape:

// Settings.js
export function Settings() {
  return <p>Settings ready</p>;
}

// App.js
const Settings = lazy(() =>
  import('./Settings.js').then((module) => ({
    default: module.Settings,
  }))
);

The adapter transforms the eventual module object into the contract lazy expects.

Filling the hole #

The smallest working path now has four parts:

import { lazy, Suspense } from 'react';

const Settings = lazy(() => import('./Settings.js'));

export default function App() {
  return (
    <Suspense fallback={<p>Loading settings...</p>}>
      <Settings />
    </Suspense>
  );
}

Each part owns one responsibility:

  1. import() starts asynchronous module loading and fulfills with a module object
  2. lazy() creates a stable component type backed by that loader
  3. rendering the lazy type suspends while its module is unavailable
  4. the closest Suspense boundary supplies a fallback description until React can retry the content

The component stays declarative. It does not need an Effect, a loading flag, or state that stores the imported type. Its location in the tree says when the code becomes necessary, and the boundary says which temporary UI can be committed.

Final definition #

A lazy component is a stable React component type whose implementation is loaded on its first render attempt. While its loader Promise is pending, rendering that type suspends. A Suspense boundary catches that temporary unavailability in its descendant tree, commits its fallback, and lets React retry the intended content when the code becomes ready.

Summary #

Lazy loading and Suspense fill the unavailable-code hole:

  • static imports make component code part of the initial dependency graph
  • dynamic import() returns a Promise for a module namespace object
  • a bundler can turn that dynamic boundary into a separately loaded chunk
  • lazy(loader) turns the asynchronous module contract into a component type
  • the loader runs when React first attempts to render that type
  • the module must resolve to a valid component at .default
  • a pending lazy component suspends rendering beneath it
  • the closest Suspense boundary commits its fallback until the content is ready
  • boundary placement determines which UI waits and reveals together
  • separate or nested boundaries can express progressive reveal points
  • lazy caches its loader Promise and resolved module
  • lazy component declarations belong at module scope so their identity remains stable
  • Effect-driven or event-driven loading does not activate Suspense by itself

Notes

  1. The live demos in this article use the React 19.2.5 and react-dom 19.2.5 development modules. Controlled Promises make the intermediate fallback commits visible deterministically; real loading time comes from the module and the network.
  2. Suspense can coordinate more than lazy component code, but those uses need their own loading integration and rendering model. This article stays with `React.lazy()` so that code availability is the only new variable. · Back

References

  1. React `lazy` API reference (opens in a new tab) · Back
  2. React `Suspense` API reference (opens in a new tab) · Back
  3. MDN: Dynamic `import()` (opens in a new tab) · Back
  4. Webpack: Code splitting (opens in a new tab) · Back