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.
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.
// 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.
Step-by-step verification
-
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.
-
Confirm the preload fires on intent. Hover the trigger without clicking. The chunk request should appear immediately, before any click.
-
Confirm one request per session. Move the pointer on and off the trigger repeatedly; there must be exactly one request, not one per crossing.
-
Measure the perceived open time. With the network throttled, record from
pointerdownto the dialogβs first paint. Preloaded, this should be under 100 ms; without preloading it is the full fetch duration. -
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.
-
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.
Related
- Component-Level Code Splitting Beyond Routes β the parent guide on choosing what to split below the route boundary
- Code Splitting Chart Libraries in Dashboards β the same technique for the heaviest widget category
- Designing Suspense Fallbacks That Avoid Layout Shift β reserving the box the dialog body will occupy
- Prefetch and Preload Strategies for Critical Routes β scheduling fetches when there is no intent signal