Lazy Loading Modal and Dialog Components

The symptom is familiar from the other side of the split. You move a settings dialog behind a dynamic import, the route chunk drops by 40 KB, and the change ships. Then the support queue fills with reports that the settings button β€œdoes nothing for a second.” In DevTools, the Network panel shows exactly what happened:

Name                          Status  Type    Size     Time
settings-dialog-B7hq2m.js     200     script  41.2 kB  612 ms

Six hundred milliseconds of nothing between the click and the dialog appearing β€” an interaction that used to be instant. The bytes moved off the critical path and onto the interaction path, which for a frequently-used dialog is a bad trade made invisibly.

Modals are the most commonly split component category and the easiest one to split badly. This page covers the pattern that keeps the byte saving without the perceived delay.

Root cause: the import starts at the worst possible moment

A modal split naively couples two things that should be independent: when the code is fetched and when the dialog opens. Click handlers do both at once, so the user waits for the network.

The interaction-probability logic from component-level code splitting beyond routes still applies β€” a dialog opened by a minority of sessions is a legitimate split candidate. What changes for modals is scheduling. Unlike a below-the-fold chart, a dialog is opened by a deliberate, predictable action, and the browser gets a strong signal before that action completes: the pointer enters the trigger, or the trigger receives keyboard focus. That signal typically arrives 150–400 ms before the click.

Import on Click Versus Preload on Intent With the import starting on click, the user waits the full fetch duration. Starting the same fetch on pointer enter overlaps it with the time the user spends moving and pressing, so the dialog renders immediately on click. Import on click β€” the user waits for the network pointer enters click fetch chunk β€” nothing on screen dialog appears 612 ms Preload on intent β€” the fetch overlaps the user's own latency pointer enters fetch chunk in the background click dialog appears β‰ˆ 40 ms perceived Same bytes, same network time β€” only the moment the fetch starts has changed

The fix: split the body, preload on intent, render the shell immediately

Three changes, applied together. Split at the dialog content boundary rather than the whole dialog. Start the import when intent is signalled. Render the overlay synchronously so the click always produces immediate visual feedback.

Where to Draw the Split Line The overlay, backdrop and positioning shell stay in the route chunk; only the dialog body and its unique dependencies move behind the boundary. Split at the body, not at the dialog stays in the route chunk trigger button open state overlay + backdrop β€” about 1.5 KB deferred chunk dialog body + form library validation rules, 41 KB The shell can render on click even when the body has not arrived yet
// SettingsDialogTrigger.jsx β€” React 18+, Webpack 5 or Vite 5+
import { lazy, Suspense, useCallback, useRef, useState } from 'react';
import { Overlay } from './Overlay';   // ~1.5 KB, stays in the route chunk

const loadBody = () => import('./SettingsDialogBody');
const SettingsBody = lazy(loadBody);

export function SettingsDialogTrigger() {
  const [open, setOpen] = useState(false);
  const started = useRef(false);

  // Intent: fires on hover and on keyboard focus, ~150–400 ms before the click.
  const preload = useCallback(() => {
    if (started.current) return;   // one request, however many times intent fires
    started.current = true;
    loadBody();
  }, []);

  return (
    <>
      <button
        onPointerEnter={preload}
        onFocus={preload}
        onClick={() => setOpen(true)}
      >
        Settings
      </button>

      {open && (
        // The overlay renders synchronously: the click always produces feedback,
        // even on the rare session where the chunk is still in flight.
        <Overlay onClose={() => setOpen(false)}>
          <Suspense fallback={<div style={{ minHeight: 360 }} aria-busy="true" />}>
            <SettingsBody onClose={() => setOpen(false)} />
          </Suspense>
        </Overlay>
      )}
    </>
  );
}

The started ref matters more than it looks. Without it, every pointer movement across the trigger fires another import call; the module registry deduplicates the network request, but the repeated calls still allocate promises and, in some router integrations, retrigger transition state.

Vue 3 expresses the same shape with defineAsyncComponent, where the loader function is the preload hook:

// SettingsDialogTrigger.vue script block β€” Vue 3.4+, Vite 5+
import { defineAsyncComponent, ref } from 'vue';

const loadBody = () => import('./SettingsDialogBody.vue');
const SettingsBody = defineAsyncComponent({
  loader: loadBody,
  delay: 200,          // no spinner flash when the chunk is already resolved
});

const open = ref(false);
let started = false;
function preload() {
  if (started) return;
  started = true;
  loadBody();          // warms the module registry; the component reuses it
}
</script>

Keeping focus management correct

A dialog has accessibility obligations that a lazily-loaded body complicates: focus must move into the dialog on open, be trapped inside it, and return to the trigger on close. If focus is moved at open time while the body is still loading, there is nothing focusable inside the dialog, and focus falls back to the document body β€” the keyboard user is dropped at the top of the page with no indication of where they are.

// Overlay.jsx β€” hold focus on the container until real content mounts
import { useEffect, useRef } from 'react';

export function Overlay({ children, onClose }) {
  const container = useRef(null);

  useEffect(() => {
    // The container is focusable via tabIndex={-1}, so focus is valid and
    // trapped even while the body chunk is still in flight.
    container.current?.focus();
  }, []);

  return (
    <div
      ref={container}
      role="dialog"
      aria-modal="true"
      tabIndex={-1}
      onKeyDown={(e) => e.key === 'Escape' && onClose()}
    >
      {children}
    </div>
  );
}

The loaded body then moves focus to its own first control in its own mount effect, which runs after the chunk resolves. Focus is therefore always somewhere valid: on the container while loading, inside the content once it exists.

Focus Handoff Across the Lazy Boundary Focus moves from the trigger button to the focusable dialog container while the chunk loads, then into the first control of the loaded content, and returns to the trigger when the dialog closes. Trigger button focus starts here Dialog container tabIndex βˆ’1, holds focus Loaded content first control focused open mount on close, focus returns to the trigger Focus is never on the document body, even while the chunk is in flight

Step-by-step verification

  1. Confirm the chunk is deferred. Load the route with an empty cache and confirm the dialog chunk is absent from the Network panel until you interact with the trigger.

  2. Confirm the preload fires on intent. Hover the trigger without clicking. The chunk request should appear immediately, before any click.

  3. Confirm one request per session. Move the pointer on and off the trigger repeatedly; there must be exactly one request, not one per crossing.

  4. Measure the perceived open time. With the network throttled, record from pointerdown to the dialog’s first paint. Preloaded, this should be under 100 ms; without preloading it is the full fetch duration.

  5. Test the keyboard path. Tab to the trigger and press Enter. The dialog must open, focus must land inside it, Escape must close it, and focus must return to the trigger.

  6. Test the cold-click path. Disable the preload temporarily and click directly. The overlay must still appear instantly with a reserved box, not a collapsed container that expands when content arrives.

Edge cases and gotchas

Touch devices have no hover. pointerenter never fires on a tap-only device, so mobile users always take the cold path. Add a touchstart preload β€” it fires before click by roughly 100–300 ms, which is enough to hide most of the fetch on a warm connection.

Dialogs opened programmatically. A dialog triggered by a timer, a route change, or a server-sent event has no intent signal at all. For those, preload during idle time after the route settles, using the scheduling approach described in prefetch and preload strategies for critical routes.

Nested dialogs. A confirmation dialog opened from inside a lazily-loaded dialog serializes two chunk fetches. Import the confirmation chunk alongside the parent’s, or keep small confirmation dialogs unsplit β€” they are rarely worth their own round-trip.

Chunk failure while the overlay is open. If the body chunk never arrives, the user is left staring at an empty modal with no way to understand what happened. The overlay needs its own error boundary, following the pattern in handling lazy chunk load failures and fallbacks.

FAQ

Why does my lazy modal open with a visible delay?

Because the import starts on click, so the user waits for a network round-trip before anything appears. The fix is to decouple the two: open the overlay shell immediately on click, and start the import earlier β€” on pointerenter or focus of the trigger. By the time the click completes, the chunk is usually already resolved, and the perceived delay disappears even though the network cost is unchanged.

Should the modal overlay itself be lazy loaded?

No. The overlay, backdrop, and positioning shell are typically under 2 KB and are shared by every dialog in the application, so deferring them buys nothing and adds a round-trip to the critical open path. Split at the boundary of the dialog’s content and its unique dependencies β€” the form library, the editor, the chart β€” and keep the generic shell in the route chunk where it can render the instant the trigger fires.

Does lazy loading a dialog break focus trapping?

It does if focus is moved at open time, because at that moment the dialog body does not exist yet and there is nothing focusable inside it β€” focus falls back to the document body and keyboard users lose their place. Move focus in an effect that runs after the loaded content mounts, and keep the container focusable in the interim so the focus trap has somewhere valid to hold focus while the chunk is in flight.