50% off your first 3 months See plans →

React.lazy() isn't enough — my code-split chunks were still loading on every page

Haider Ali· · 6 min read

Both components were already wrapped in lazy(). Both chunks downloaded on first paint anyway, costing 103KB on every page view. The reason is that lazy() defers the import until the component renders — and both were rendering.

I ran my own site through my own speed tool last week and it failed. Performance 80, Largest Contentful Paint 3.8s — Google's threshold is 2.5s. Which is awkward, because the site sells speed optimisation.

Fixing it turned up something I'd had wrong for a while: React.lazy() does not stop a chunk from downloading. It stops it from being in the main bundle. Those are different things, and the difference was costing me ~103KB on every single page view.

The setup

Two components imported the OpenAI SDK:

Both were already lazy:

const ChatBot = lazy(() => import('./components/ChatBot'));
const AIDemos = lazy(() => import('./AIDemos'));

Main bundle went from 464KB to 343KB. Job done, I assumed.

What the network tab actually said

index-DYK5VJqa.js     ← main bundle
AIDemos-CUdstf0G.js   ← "lazy"
client-BKVqCv_k.js    ← the OpenAI SDK, 103KB

All three, on first paint, before any interaction.

The reason is obvious in hindsight: lazy() defers the import until the component renders — and both components were rendering. ChatBot mounted on every page (visually collapsed, but mounted). AIDemos sat in the tree below the fold, and React doesn't care about the fold. It renders, so the chunk downloads. Immediately.

Suspense boundaries don't change this. A <Suspense> wrapper describes what to show while a chunk loads. It doesn't decide whether to load it.

lazy() splits the bundle. It doesn't defer the fetch. The fetch is deferred by not rendering the component — which is a thing you have to arrange yourself.

Fix 1: render a button, not the widget

For the chat, the trigger doesn't need the chat code — it's a button. So the button is the only thing that ships:

const ChatBot = lazy(() => import('./ChatBot'));

export default function ChatLauncher() {
  const [loaded, setLoaded] = useState(false);

  if (loaded) {
    return (
      <Suspense fallback={<TriggerButton busy />}>
        <ChatBot initiallyOpen />
      </Suspense>
    );
  }

  return (
    <TriggerButton
      onClick={() => setLoaded(true)}
      // Warm the chunk on hover so the click feels instant
      onPrefetch={() => { void import('./ChatBot'); }}
    />
  );
}

Two details that matter more than they look:

initiallyOpen. The user already clicked. If ChatBot mounts with its own isOpen defaulting to false, that click is swallowed and they have to click again. The prop exists purely so the click isn't lost across the chunk boundary.

The prefetch on hover/focus/touch. import() is idempotent, so calling it early just warms the cache. By the time the click lands the chunk is usually there. On mobile there's no hover, but onTouchStart fires before onClick, which buys a few dozen milliseconds.

Make the placeholder button visually identical to the real one, or you get a flicker at swap time.

Fix 2: don't render below-the-fold sections until they're near

For AIDemos there's no click to hang it on. It just needs to not render until it's nearly visible:

export default function DeferUntilVisible({
  children,
  minHeight = 400,
  rootMargin = '300px',
}) {
  const ref = useRef(null);
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    // No IntersectionObserver? Render now. Never hide content behind
    // a feature check — a crawler with no IO support should still see it.
    if (typeof IntersectionObserver === 'undefined') {
      setVisible(true);
      return;
    }

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) {
          setVisible(true);
          observer.disconnect();
        }
      },
      { rootMargin },
    );

    observer.observe(el);
    return () => observer.disconnect();
  }, [rootMargin]);

  return (
    <div ref={ref} style={visible ? undefined : { minHeight }}>
      {visible ? children : null}
    </div>
  );
}

Used like this:

<DeferUntilVisible minHeight={600}>
  <Suspense fallback={<div className="py-20" />}>
    <AIDemos />
  </Suspense>
</DeferUntilVisible>

The two parameters are doing real work:

And the IntersectionObserver fallback is not defensive padding — if you render nothing when the API is missing, you've hidden real content from anything that doesn't implement it.

Results

BeforeAfter
JS chunks on page view31
Transferred154KB109KB
Performance8090
LCP3.8s2.9s

LCP is still above 2.5s. I'm not going to pretend otherwise — the remaining cost is React itself on a page that's already prerendered, and that's an architectural problem, not a tuning one.

The thing worth checking on your own app

Open the network tab, filter to JS, hard-reload, and don't touch anything. Every chunk you see is on your critical path regardless of what lazy() implied.

Then ask, for each one: is the component that imports this actually rendering right now? If yes, splitting it bought you nothing but an extra request.

The mental model that stuck for me: lazy() is about where code lives, not when it loads. When it loads is decided by whether you render the component — and that's a question about your component tree, not your bundler.

Found this because your own site is slow? The free speed test runs the same checks against any URL, no signup. Or see what we do about it on custom web development.

Get a free homepage mockup

Tell us about your business and we'll design a custom homepage concept within 48 hours — free, no strings attached.