Understand lazy loading
- New
- Explanation
- 14-min read
Code splitting is about breaking your app into smaller bundles so you're not shipping everything upfront, while lazy loading is about delaying the loading of those bundles until they're actually needed. In practice, code splitting makes lazy loading possible, and lazy loading is how you get the real performance wins from those split chunks. Together, they keep initial loads fast and only download code when the user hits the feature that needs it.
The App Toolkit now supports fine-grained asynchronous module loading via React's lazy() method and Suspense component. This allows you to split your app bundle into smaller chunks that are loaded on demand, reducing initial load times, improving time-to-interactive, and keeping your Core Web Vitals in check. Core Web Vitals are Google's metrics for measuring real-world user experience:
-
LCP (Largest Contentful Paint)—measures loading performance. It marks when the largest visible element such as an image, text block, or video finishes rendering.
-
INP (Interaction to Next Paint)—measures interactivity and responsiveness. It tracks the delay between a user interaction such as a click, tap, or key press and the next visual update.
-
CLS (Cumulative Layout Shift)—measures visual stability. It scores how much page elements unexpectedly shift during the page's lifetime. For example, an image loads and pushes content down.
To learn how to enable code splitting in your Dynatrace app, see Lazy loading quickstart.
How it works
React's lazy() method accepts a function that returns a dynamic import(). The returned component is loaded asynchronously and must be rendered inside a <Suspense> component boundary that provides a fallback UI while the chunk is in flight.
See the following example of lazy loading a heavy component:
import React, { lazy, Suspense } from 'react';
import { ProgressBar } from '@dynatrace/strato-components/content';
const HeavyComponent = lazy(() => import('./components/HeavyComponent'));
export const App = () => (
<Suspense fallback={<ProgressBar />}>
<HeavyComponent />
</Suspense>
);
The component module is only fetched when <HeavyComponent /> is first rendered. Subsequent renders use the cached module and there is no per-render overhead.
Route-based lazy loading
The highest-impact and lowest-risk place to introduce lazy loading is at the route level. Each page of your app is already a natural boundary: users navigate to one page at a time, and there is an inherent expectation of a brief transition between pages.
Before
A typical Dynatrace app imports all page components eagerly in App.tsx:
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { Overview } from './pages/Overview';
import { HostDetails } from './pages/HostDetails';
import { Settings } from './pages/Settings';
import { Changelog } from './pages/Changelog';
export const App = () => (
<Routes>
<Route path="/" element={<Overview />} />
<Route path="/host/:id" element={<HostDetails />} />
<Route path="/settings" element={<Settings />} />
<Route path="/changelog" element={<Changelog />} />
</Routes>
);
Every page and all of its transitive dependencies ship in the initial bundle regardless of which route the user actually navigates to.
After
Replace static imports with lazy() and wrap your route tree in a <Suspense> boundary:
import React, { lazy, Suspense } from 'react';
import { Route, Routes } from 'react-router-dom';
import { ProgressBar } from '@dynatrace/strato-components/content';
const Overview = lazy(() => import('./pages/Overview'));
const HostDetails = lazy(() => import('./pages/HostDetails'));
const Settings = lazy(() => import('./pages/Settings'));
const Changelog = lazy(() => import('./pages/Changelog'));
export const App = () => (
<Suspense fallback={<ProgressBar />}>
<Routes>
<Route path="/" element={<Overview />} />
<Route path="/host/:id" element={<HostDetails />} />
<Route path="/settings" element={<Settings />} />
<Route path="/changelog" element={<Changelog />} />
</Routes>
</Suspense>
);
Now only the code for the active route is fetched on initial load. Navigating to /settings triggers a network request for the Settings chunk, with the <ProgressBar /> fallback shown until it resolves.
Default exports
React.lazy() expects the dynamically imported module to have a default export containing the React component. Make sure your page modules export accordingly:
import React from 'react';
const Settings = () => {
return <div>Settings</div>;
// ...
};
export default Settings;
If your component uses a named export, wrap the import:
const Settings = lazy(() => import('./pages/Settings').then((module) => ({ default: module.Settings })));
The default export pattern is cleaner. If you're introducing lazy loading to an existing app, switching page entry points to default exports is a reasonable refactor.
View-based lazy loading
Not every meaningful split is a route. Within a single page, you often have distinct views or panels that the user doesn't see upfront such as tabs, drawers, detail panels, modals, or expandable sections. These are the next tier of lazy loading candidates.
When to lazy load a view
Apply lazy loading to in-page views when:
- The view is behind an interaction—a tab the user has to click, a drawer that opens, a modal triggered by a button. The user expects a brief moment of loading when they perform the action.
- The view has significant weight—it pulls in heavy dependencies (charting libraries, complex forms, code editors) or renders large component trees.
- The view is not on the default visible area—a secondary tab, an "Advanced" section, a detail panel that appears on row selection.
Don't lazy load things that are visible immediately on page render. If a component is above the fold and visible on initial paint, eagerly load it; lazy loading it just delays the LCP with no benefit.
Example: tabbed layout
import React, { lazy, Suspense } from 'react';
import { Tabs, Tab } from '@dynatrace/strato-components/navigation';
import { Skeleton } from '@dynatrace/strato-components/content';
// The overview tab is the default — load it eagerly
import { OverviewTab } from './tabs/OverviewTab';
// Secondary tabs are lazy loaded
const MetricsTab = lazy(() => import('./tabs/MetricsTab'));
const LogsTab = lazy(() => import('./tabs/LogsTab'));
const ConfigTab = lazy(() => import('./tabs/ConfigTab'));
export const HostDetails = () => (
<Tabs>
<Tab title="Overview">
<OverviewTab />
</Tab>
<Tab title="Metrics">
<Suspense fallback={<Skeleton />}>
<MetricsTab />
</Suspense>
</Tab>
<Tab title="Logs">
<Suspense fallback={<Skeleton />}>
<LogsTab />
</Suspense>
</Tab>
<Tab title="Configuration">
<Suspense fallback={<Skeleton />}>
<ConfigTab />
</Suspense>
</Tab>
</Tabs>
);
export default HostDetails;
The default Overview tab loads eagerly because it's visible immediately. Secondary tabs load their chunks only when selected. The <Skeleton /> fallback matches the expected layout shape, avoiding jarring layout shifts.
Example: modal or drawer
import React, { lazy, Suspense, useState } from 'react';
import { Button } from '@dynatrace/strato-components/buttons';
import { Modal } from '@dynatrace/strato-components/overlays';
import { ProgressBar } from '@dynatrace/strato-components/content';
const BulkEditForm = lazy(() => import('./BulkEditForm'));
export const HostActions = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button onClick={() => setIsOpen(true)}>Bulk Edit</Button>
{isOpen && (
<Modal title="Bulk Edit" show onDismiss={() => setIsOpen(false)}>
<Suspense fallback={<ProgressBar />}>
<BulkEditForm onClose={() => setIsOpen(false)} />
</Suspense>
</Modal>
)}
</>
);
};
The <BulkEditForm /> chunk is only fetched when the modal opens. The {isOpen && ...} conditional rendering ensures the lazy component isn't even mounted until needed.
Parallelizing chunk loading with Grail queries
A common pattern in Dynatrace apps is that the user navigates to a page that needs to run a heavy DQL query against Grail and render a complex visualization component. Without lazy loading, the browser first downloads the entire bundle (including the visualization code), then mounts the component, and only then fires the query. The user waits for both sequentially.
With lazy loading, you can kick off the Grail query and the chunk download at the same time. The query starts immediately in the parent component—which is already loaded—while the heavy child component streams in via a lazy import. By the time the chunk arrives, the query may already have results or at least a head start.
The pattern
The key insight is that useDql runs in the parent component that's already in the bundle. The lazy-loaded child only handles rendering the results. This decouples data fetching from component loading. See the following example:
import React, { lazy, Suspense } from 'react';
import { useDql } from '@dynatrace-sdk/react-hooks';
import { Skeleton } from '@dynatrace/strato-components/content';
import { Paragraph } from '@dynatrace/strato-components/typography';
// The visualization component is heavy — lazy load it
const HostTopologyMap = lazy(() => import('./HostTopologyMap'));
export const TopologyPage = () => {
// Query fires immediately when this page mounts — no waiting for the chunk
const { data, isLoading, error } = useDql({
query: `fetch dt.entity.host
| fieldsAdd tags, managementZones, hostGroupName
| join [
fetch dt.entity.process_group
| fieldsAdd run_hosts = runs_on[dt.entity.host], pg_name = entity.name
| expand run_hosts
],
on: { left[id] == right[run_hosts] },
fields: { pg_name }
| fieldsAdd processGroups = pg_name
| fields id, entity.name, tags, managementZones, hostGroupName, processGroups
| sort entity.name asc
| limit 500`,
});
if (error) {
return <Paragraph>Error: {error.message}</Paragraph>;
}
return (
<Suspense fallback={<Skeleton height={400} />}>
<HostTopologyMap data={data} isLoading={isLoading} />
</Suspense>
);
};
export default TopologyPage;
What's happening at runtime:
- User navigates to the topology page.
TopologyPageis either already bundled, if it's the current route, or loaded via its own lazy boundary.- Immediately on mount,
useDqlfires the Grail query. This is a network request to the backend. - Simultaneously, React hits the
<Suspense>boundary, which triggers the dynamicimport()for theHostTopologyMapchunk. This is a network request for the JavaScript asset. - Both requests fly in parallel. The
<Skeleton />fallback is shown until the chunk resolves. - Once
HostTopologyMapmounts, it receivesdata(possibly already resolved) andisLoadingas props and renders accordingly.
Without this pattern, steps 3 and 4 would be sequential: first download everything, then mount, then query. The parallel approach can save seconds on pages with heavy queries and heavy visualization components.
When this matters most
This pattern has the highest impact when:
- The query is slow—complex joins, large result sets, or cross-entity lookups that take multiple seconds.
- The visualization component is heavy—topology maps, complex charts, or anything pulling in large rendering dependencies.
- Both conditions are true—which is often the case, since the pages that need heavy queries are usually the same pages that need heavy visualizations to display them.
For lightweight queries that return in under 200ms, the parallelization gain is negligible. However, lazy-loading the component itself is still valuable for reducing initial bundle size.
Finding the right boundary
Not every component should be lazy loaded. There's overhead involved: a new chunk, a network request, a Suspense boundary, or a fallback UI. The question is whether the code you're deferring is worth that trade-off.
Good candidates
| Pattern | Why |
|---|---|
| Pages / Routes | Natural navigation boundary. Users expect a brief transition. High impact because each page pulls in its own dependency tree. |
| Secondary tabs | Hidden behind interaction. Often heavy with dedicated data fetching and visualizations. |
| Modals and drawers | Not rendered until triggered. Can contain complex forms or detail views. |
| Settings / Configuration panels | Rarely visited. Often includes form libraries and validation logic. |
| Heavy visualization components | Charting, graphing, or topology views that import large rendering libraries. |
Poor candidates
| Pattern | Why |
|---|---|
| Small, shared UI components | Buttons, labels, icons — the overhead of a lazy boundary far outweighs the bytes saved. |
| Components visible on initial render | Lazy loading them delays LCP. You're making the first paint worse, not better. |
| Deeply nested internal components | Splitting at too granular a level creates a waterfall of chunk requests. |
| Components used on every page | They'll be loaded on the first navigation regardless. Shared code belongs in the main bundle. |
Rule of thumb
If you're deferring less than ~20-30 KB of uncompressed JavaScript, the overhead of a separate chunk probably isn't worth it. Focus on boundaries where you're deferring entire feature surfaces with their own dependency subtrees.
Strato components and lazy loading
Larger Strato components from @dynatrace/strato-components—such as DataTable, CodeEditor, and complex charting components—already implement internal lazy loading. Their heavier rendering internals are split into separate chunks that load on demand. You don't need to wrap these in your own lazy(), their chunk splitting is handled transparently. This means your app benefits from Strato's internal optimizations automatically, as long as you keep Strato up to date.
What you should still lazy load is the page or view that uses these components. Even if DataTable handles its own internals, the page component that composes it with your app's data fetching, layout, and business logic is still worth deferring if it's behind a route or interaction boundary.
Impact on Largest Contentful Paint (LCP)
Lazy loading directly influences your Largest Contentful Paint (LCP), which is the Core Web Vital that measures how quickly the largest visible element renders. The target is less than 2.5 seconds. The relationship isn't always straightforward though; lazy loading can improve or hurt LCP depending on where you apply it.
Why it helps
A monolithic bundle forces the browser to download, parse, and execute all of your app's JavaScript before anything renders. The LCP element—typically a data table, a chart, or a heading within the active page—can't paint until this work completes.
By splitting non-critical routes and views into separate chunks, you reduce the size of the initial bundle. Less JavaScript to parse means the browser reaches first render faster, which brings LCP forward.
Typical impact on a multi-page app
The numbers depend on your app, but the direction is consistent: less upfront JavaScript means faster LCP.
| Scenario | Initial bundle | LCP (approx.) |
|---|---|---|
| No lazy loading (4 pages, all eagerly loaded) | ~1.2 MB | ~3.8s |
| Route-level lazy loading (only active page in initial bundle) | ~450 KB | ~2.1s |
| Route + view-level lazy loading (secondary tabs deferred) | ~320 KB | ~1.7s |
Where it can hurt
If you lazy load a component that is the LCP element itself—for example, the main data table or the primary heading on the landing page—you introduce an additional network round-trip before that element can render. The Suspense fallback such as a skeleton or a progress bar, renders first, and only after the chunk arrives does the actual LCP element paint. This delays LCP rather than improving it.
The rule is simple: the component responsible for your LCP element must be in the initial bundle. Everything else is a candidate for deferral.
Fallbacks and Cumulative Layout Shift (CLS)
Your Suspense fallback also matters for Cumulative Layout Shift (CLS). If the fallback has a different height or layout than the resolved component, the page shifts when the real content renders. This is why using <Skeleton /> components that approximate the final layout is important not just for perceived performance, but to keep CLS under the 0.1 threshold.
Measuring the effect
Use the browser Developer Tools Performance panel or Lighthouse to measure LCP before and after introducing lazy loading. Pay attention to:
- LCP element identification—make sure you know which element the browser considers the LCP. It may not be what you expect.
- Chunk waterfall—in the Network tab, verify that lazy chunks load in parallel with (or after) the initial bundle, not in a sequential waterfall.
- Suspense duration—the time between fallback render and actual component render is the cost of lazy loading that boundary. If it's consistently > 500ms for above-the-fold content, that boundary is in the wrong place.
Guidelines
To recap, here are some best practices for applying lazy loading in your Dynatrace app:
-
Start at the route level—this is the highest leverage point and requires the least architectural change. Every app with more than one page should do this.
-
Default tab eagerly, secondary tabs lazily—if a page has a tabbed layout, load the initially visible tab eagerly and lazy load the rest.
-
Lazy load what's behind interactions—modals, drawers, expandable panels — anything that requires a user action to become visible is a safe lazy boundary.
-
Don't lazy load what's immediately visible—components that render above the fold on page load should be in the main bundle.
-
Match your fallback to the expected layout—use
<Skeleton />components that approximate the shape of the incoming content. Avoid full-page spinners for in-page views — they cause unnecessary layout shift. -
Combine with error boundaries—always pair
<Suspense>with an error boundary for lazy-loaded content to degrade gracefully on network failures. -
Keep Strato up to date—larger Strato components from
@dynatrace/strato-componentshandle their own internal lazy loading. Staying current ensures you benefit from ongoing bundle optimizations.