Skip to main content

Prevent layout shift in Dynatrace Apps

  • New
  • Explanation
  • 10-min read

What CLS measures

Cumulative Layout Shift (CLS) quantifies how much visible content moves around unexpectedly during a page's lifetime; our target is < 0.1. Every time a visible element shifts position without being triggered by a user interaction, the browser records a layout shift score based on the element's size and the distance it moved. CLS is the sum of the largest burst of these scores.

The unintuitive part: CLS is not limited to initial page load. Shifts that happen seconds or minutes into a session—when an async query resolves, when a lazy chunk lands, when a toast appears and pushes content down—all count. An app can have a perfect first paint and a terrible CLS because of what happens after the user starts interacting.

What doesn't count

Shifts that happen within 500 ms of a discrete user interaction (such as selection, navigation, or key presses) are excluded. The 500 ms exclusion window means a table re-sorting when selected, a panel expanding on toggle, or a drawer opening are not CLS violations—the user caused them. What does count is content moving because something loaded asynchronously without the user asking for it.

Why CLS is a problem in Dynatrace apps

Dynatrace apps have a pattern profile that commonly triggers CLS:

  • Async data everywhere—almost every component fetches data from Grail via useDql. When results arrive, components grow from empty/skeleton to full content.
  • Lazy-loaded components—with lazy loading enabled (see Lazy Loading Guide), Suspense fallbacks are swapped for real components, potentially with different dimensions.
  • Complex layouts—tabbed views, split panels, data tables, and charts are composed together. A shift in one component cascades to everything below it.
  • Dynamic notifications—banners, toasts, and inline alerts injected into document flow.
Reserve space for content before it arrives.

Skeleton and placeholder sizing

The most common CLS source in Dynatrace apps is the transition from a loading state such as a skeleton or a spinner, to the actual content. If the skeleton is shorter or taller than the resolved component, everything below shifts.

The problem

// ❌ Bad: Skeleton has no height constraint—likely shorter than the actual table
if (isLoading) return <Skeleton />;
return <DataTable data={data.records} />;

The <Skeleton /> might render as a couple of pulsing lines (~60px tall). The <DataTable /> renders as 500px of rows. When data arrives, the 440px difference pushes everything below the table down—a massive layout shift.

Match skeleton dimensions

// ✅ Good: Skeleton occupies the same space as the expected content
if (isLoading) {
return (
<div style={{ minHeight: 500 }}>
<Skeleton />
</div>
);
}

return <DataTable data={data.records} />;

Better yet, use multiple skeleton lines that approximate the table's row structure:

if (isLoading) {
return (
<div style={{ minHeight: 500, display: 'flex', flexDirection: 'column', gap: 8 }}>
<Skeleton height={40} /> {/* Header row */}
{Array.from({ length: 10 }, (_, i) => (
<Skeleton key={i} height={36} /> /* Data rows */
))}
</div>
);
}

Avoid the skeleton entirely with SWR

If you're using useDql with staleTime or placeholderData, revisits to a page render cached data instantly; no skeleton, no shift:

const { data, isLoading, isFetching } = useDql(query, {
staleTime: 5 * 60 * 1000,
});

// Skeleton only on the very first visit—all revisits show cached data
if (isLoading) {
return (
<div style={{ minHeight: 500 }}>
<Skeleton />
</div>
);
}

return (
<div style={{ opacity: isFetching ? 0.7 : 1, transition: 'opacity 200ms' }}>
<DataTable data={data.records} />
</div>
);

The opacity transition signals a background refresh without moving anything. Zero CLS on revisits.

Note

DataTable and dynamic row counts

Tables are the most layout-shift-prone component in data-heavy apps. A DataTable that goes from 0 rows to 50 rows shifts everything below it dramatically.

Reserve space for the table container

Wrap your table in a container with a fixed or minimum height. The exact value depends on your expected row count and row height:

<div style={{ minHeight: 600 }}>
<DataTable data={data?.records ?? []} columns={columns} />
</div>

If the table renders fewer rows than expected, the container holds the space. If it renders more, the container grows naturally—which is fine because content only shifts downward beyond the fold, and that has minimal CLS impact since the user isn't looking there.

Pagination vs. infinite scroll

Pagination is CLS-friendly. A paginated table has a predictable, fixed height per page. The container height doesn't change when the user navigates between pages—the content swaps in place.

Infinite scroll can cause CLS if new rows are appended above the fold or if the scroll container isn't properly sized. If you use infinite scroll, make sure:

  • New rows are appended below the current viewport (which is the natural behavior).
  • The scroll container has a fixed height with overflow: auto—content grows inside the container, not outside it.
// ✅ Fixed-height scroll container—no external layout shift
<div style={{ height: 600, overflow: 'auto' }}>
<DataTable data={allLoadedRecords} columns={columns} />
</div>

Empty states

When a query returns zero results, don't collapse the table container to zero height. Show an empty state placeholder that matches the table's expected dimensions:

<div style={{ minHeight: 400 }}>
{data?.records?.length === 0 ? (
<EmptyState message="No hosts match the current filter." />
) : (
<DataTable data={data.records} columns={columns} />
)}
</div>

Lazy-loaded components and Suspense boundaries

When using lazy loading, a Suspense fallback is shown while the chunk downloads. When the real component mounts, it replaces the fallback. If the two have different heights, you get a layout shift.

Size your Suspense wrapper

// ❌ Bad: ProgressBar is ~4px tall, the actual component is 400px
<Suspense fallback={<ProgressBar />}>
<MyAppMetricsTab />
</Suspense>
// ✅ Good: Wrapper reserves space for the expected content
<Suspense
fallback={
<div style={{ minHeight: 400 }}>
<Skeleton />
</div>
}
>
<MyAppMetricsTab />
</Suspense>

Route-level transitions

For route-level lazy loading, the entire page content swaps. Route-level swaps are less of a CLS problem because the entire viewport changes—there's no surrounding content to shift. But if your app shell (header, sidebar) is stable and only the main content area swaps, make sure the content area has a minimum height:

<main style={{ minHeight: 'calc(100vh - 64px)' }}>
{' '}
{/* 64px for header */}
<Suspense fallback={<MyAppSkeleton />}>
<Routes>
<Route path="/" element={<MyAppOverview />} />
<Route path="/settings" element={<MyAppSettings />} />
</Routes>
</Suspense>
</main>

Reserving this minimum height prevents the footer (if any) from jumping up during route transitions.

Async Strato components

Larger Strato components like DataTable, CodeEditor, and complex charts lazy load their internals—they handle chunk-splitting transparently. Lazy-loading internals means a Strato component's initial paint might be a lightweight placeholder while the heavy internals load.

Strato's internal loading behavior is not configurable. Instead, control the layout by sizing the container around the component. Wrap heavy Strato components in a fixed-dimension container so that when the internal chunk lands and the full component renders, the surrounding layout doesn't reflow:

// Chart component whose internal rendering engine loads asynchronously
<div style={{ height: 350, width: '100%' }}>
<TimeseriesChart data={metricsData} />
</div>

The explicit height ensures the chart's container is stable from the first frame, regardless of when the chart's internal rendering completes.

Dynamic content injection

Toasts, banners, and inline alerts that appear asynchronously can cause layout shift if they're inserted into document flow and push existing content down.

Toasts and notifications

Toasts should render in a fixed-position overlay, never in document flow. Strato's Toast and notification components already do this—they render in a portal at a fixed position on the page. If you're building custom notifications, follow the same pattern:

// ❌ Bad: Banner inserted at top of page, pushes everything down
<div>
{showBanner && <MyAppBanner message="Deployment complete" />}
<MyAppContent />
</div>
// ✅ Good: Banner has reserved space, no shift when it appears
<div>
<div style={{ minHeight: 48 }}>{showBanner && <MyAppBanner message="Deployment complete" />}</div>
<MyAppContent />
</div>

Or better—use a fixed-position overlay that floats above content:

// ✅ Best: Overlay doesn't affect document flow at all
{
showBanner && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, zIndex: Elevations.Overlay }}>
<MyAppBanner message="Deployment complete" />
</div>
);
}
<MyAppContent />;

Inline validation and error messages

Form validation messages that appear below inputs push subsequent fields down. Reserve space for them:

// ❌ Bad: Error message appears and shifts the next field
<TextInput value={name} />;
{
error && <FormFieldMessages.Item variant="error">{error}</FormFieldMessages.Item>;
}
<TextInput value={email} />;
// ✅ Good: Space is always reserved for the error message
<TextInput value={name} />
<div style={{ minHeight: 24 }}>
{error && <FormFieldMessages.Item variant="error">{error}</FormFieldMessages.Item>}
</div>
<TextInput value={email} />

Measure CLS

Use the Experience Web Vitals App in Dynatrace to monitor CLS across your app in production. The app captures real-user CLS scores across sessions and lets you identify which pages and interactions produce the worst shifts.

Key things to look for:

  • Pages with CLS > 0.1—these are violations. Sort by CLS score to find the worst offenders.
  • CLS spikes correlated with navigation patterns—if CLS is high on a specific route, the Suspense fallback or skeleton on that route is likely the wrong size.
  • CLS spikes correlated with data resolution—if CLS is high a few seconds after page load, an async query resolves and causes a shift. This is where SWR with staleTime or placeholderData helps.

For local debugging during development, use the Chrome DevTools Performance panel. Enable the Layout Shift Regions checkbox in the Rendering drawer—this highlights elements as they shift in real time. The Experience section of the Performance recording shows individual shift events with their scores.

The mental model

Every piece of async content needs a container that's the right size before the content arrives.

CLS prevention means:

  • Skeletons that match the height and layout of the resolved component
  • minHeight on containers that will receive async data
  • Fixed-height scroll containers for tables
  • SWR caching (staleTime, placeholderData) to avoid loading states on revisits entirely
  • Overlays instead of flow-inserted notifications
  • Reserved space for validation messages

If you can look at your component and predict its layout before any data loads, your CLS is probably fine. If the layout depends on the shape or amount of data, you need explicit sizing.

Guidelines

To recap, here are some best practices for preventing layout shift in your Dynatrace app:

  1. Set minHeight on every container that receives async content. Whether it's a Suspense boundary, a data table wrapper, or a chart container—reserve the space ahead of time.

  2. Match skeleton dimensions to resolved content. A skeleton should approximate the height, width, and structure of the component it's standing in for. Use multiple skeleton rows for tables.

  3. Use SWR to eliminate loading states on revisits. Cached data from useDql renders instantly with no skeleton-to-content transition at all. See Stale-While-Revalidate Guide.

  4. Use placeholderData for filter changes. When query parameters change, keep the previous results visible instead of flashing to a skeleton. This eliminates a full empty-to-loaded shift.

  5. Paginate tables. Pagination gives you a fixed row count per page and a predictable container height. Prefer it over infinite scroll for CLS-critical views.

  6. Keep notifications out of document flow. Use fixed-position overlays or reserved-height slots for banners, toasts, and alerts.

  7. Monitor with Experience Web Vitals App. Regularly check your CLS scores in production. Shifts you don't notice in local development may affect real users on slower connections or different viewport sizes.

Still have questions?
Find answers in the Dynatrace Community