DEV Community

Haider Ali
Haider Ali

Posted on • Originally published at pixelandoak.com

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

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:

  • ChatBot — a floating widget, collapsed until clicked, rendered on every page
  • AIDemos — a section well below the fold on the homepage

Both were already lazy:

const ChatBot = lazy(() => import('./components/ChatBot'));
const AIDemos = lazy(() => import('./AIDemos'));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'); }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Used like this:

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

The two parameters are doing real work:

  • rootMargin: '300px' starts the fetch before the section is visible, so it's ready when you arrive instead of popping in late.
  • minHeight reserves the space. Without it you deferred a section into a zero-height div and traded a JS problem for a layout-shift problem.

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

Before After
JS chunks on page view 3 1
Transferred 154KB 109KB
Performance 80 90
LCP 3.8s 2.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.


I'm building a set of free MIT-licensed Next.js templates with this stuff baked in — a restaurant site with a real HTML menu, a photography portfolio with zero image files, a dashboard with charts drawn in SVG and no charting library. They're on GitHub if useful: github.com/haider484991. Built at Pixel & Oak.

Top comments (0)