Stale-While-Revalidate (SWR)
- New
- How-to guide
- 12-min read
Stale-While-Revalidate (SWR) is a cache invalidation strategy where the user interface (UI) immediately renders with cached (stale) data while silently fetching fresh data in the background. Once the fresh data arrives, the UI updates in place, without a loading spinner, without a blank screen, without blocking the user.
The name comes from the HTTP Cache-Control directive stale-while-revalidate, but the pattern has become a foundational concept in modern client-side data fetching.
The three phases
This strategy has three phases:
- Cache hit—returns cached data instantly. The user sees content immediately.
- Background revalidation—fires a network request to get fresh data. The user doesn't notice.
- Silent update—when fresh data arrives, it swaps it into the UI. If the data hasn't changed, nothing happens visually.
Comparing SWR to the naive approach:
| Step | Without SWR | With SWR |
|---|---|---|
| User navigates to a page | Loading spinner | Cached data renders instantly |
| Network request fires | User waits | Happens invisibly in the background |
| Data arrives | Content renders | UI updates silently if data changes |
When using SWR, the users' perception changes from "This app is slow" to "This app is fast". The user sees content immediately, and the app feels responsive even if the underlying data is slow to fetch.
Why SWR matters for Dynatrace apps
Dynatrace apps frequently display data that is:
- Expensive to query—DQL queries against Grail can take seconds, especially for complex joins, large timeframes, or high-cardinality entity lookups.
- Relatively stable—host lists, topology data, metric definitions, and configuration don't change every second.
- Repeatedly accessed—users navigate back and forth between pages, re-visit dashboards, switch between tabs.
Without SWR, every navigation or tab switch triggers a fresh query and a loading state. For a Grail query that takes 3 seconds, that's 3 seconds of spinner every time the user returns to a page they've already seen—even if the data hasn't changed.
With SWR, the second visit is instant. The user sees the data from 30 seconds ago while fresh data loads in the background. When it arrives, the table or chart updates smoothly.
useDql has built-in SWR
The useDql hook from @dynatrace-sdk/react-hooks is built on top of TanStack Query and ships with full SWR support out of the box. You don't need to add react-query separately or build your own caching layer for Grail queries—it's already there.
Key options
const result = useDql(query, {
staleTime: number, // How long cached data is considered "fresh" (ms)
refetchInterval: number, // Auto-refetch at this interval (ms)
placeholderData: data, // Data to show while the first fetch is in flight
enabled: boolean, // Set to false to disable automatic execution
});
Key return values
const {
data, // The last successfully resolved data
isLoading, // true only during the *first* fetch (no cached data yet)
isFetching, // true during *any* fetch, including background revalidation
isStale, // true if the cached data is older than staleTime
isPlaceholderData, // true if currently showing placeholderData
refetch, // Manually trigger a revalidation
forceRefetch, // Refetch ignoring staleTime
} = useDql(query, options);
The distinction between isLoading and isFetching is the core of SWR:
isLoadingistrueonly when there is no cached data at all—the very first fetch, or after the cache has been cleared. This is when you show a skeleton or loading state.isFetchingistruewhenever a network request is in flight, including background revalidations. This is not a reason to show a spinner. Use it for subtle refresh indicators.
Configure SWR for Grail queries
Set staleTime
staleTime controls how long data stays "fresh" in the cache. While data is fresh, navigating back to a component that uses the same query returns the cached result instantly—no network request at all.
Host list doesn't change often, so you can keep it fresh for 5 minutes:
const { data, isLoading } = useDql('fetch dt.entity.host | fields entity.name, state | limit 200', {
staleTime: 5 * 60 * 1000,
});
Log data is more dynamic, so you can keep it fresh for 30 seconds:
const { data, isLoading } = useDql('fetch logs | filter loglevel == "ERROR" | sort timestamp desc | limit 100', {
staleTime: 30 * 1000,
});
If no staleTime is set, data is considered stale immediately after fetching—meaning every re-render triggers a background revalidation. This is fine for highly dynamic data but wasteful for stable data.
Guidelines for choosing staleTime:
| Data type | Suggested staleTime | Reasoning |
|---|---|---|
| Entity metadata (hosts, services, process groups) | 5–10 minutes | Changes infrequently, expensive queries |
| Configuration / settings | 5–10 minutes | Rarely changes during a session |
| Metrics (timeseries) | 30–60 seconds | New data points arrive regularly |
| Logs | 15–30 seconds | Active systems produce logs continuously |
| Real-time events | 0 (or use refetchInterval) | You always want the latest |
Auto-refetch with refetchInterval
For data that should stay current without user interaction—dashboards, monitoring views, live tails—use refetchInterval:
const { data, isFetching } = useDql(
{
query: 'fetch dt.entity.host | fields entity.name, cpuUsage | sort cpuUsage desc',
maxResultRecords: 50,
},
{
staleTime: 30 * 1000,
refetchInterval: 60 * 1000, // Refresh every 60 seconds
},
);
The refetchInterval fires regardless of whether the data is stale. It's a poll. Combine it with staleTime to avoid redundant network requests when the user navigates away and returns within the stale window.
Use placeholderData
placeholderData provides an initial dataset to render while the first real fetch completes. This is useful when you have a reasonable default (e.g., an empty table structure) or when you're passing forward results from a previous query:
const { data, isPlaceholderData } = useDql(`fetch dt.entity.host | filter managementZones == "${zone}" | limit 100`, {
staleTime: 5 * 60 * 1000,
placeholderData: previousZoneData, // Show the old zone's data until new data loads
});
The isPlaceholderData flag tells you whether you're rendering real results or the placeholder. Use it to dim the UI or show a subtle indicator—never a full loading spinner.
SWR and INP: Why this matters for responsiveness
Interaction to Next Paint (INP) measures how long it takes from a user interaction (click, tap, keypress, etc.) to the next visual update. Our target is faster than 200 milliseconds. SWR has a direct and significant impact on this metric.
The problem without SWR
Consider a tab switch in a typical Dynatrace app:
- User clicks the Metrics tab.
- The app triggers a DQL query against Grail.
- The component renders a loading spinner.
- The query takes between 2 and 4 seconds.
- The data arrives. The component re-renders with the actual content.
The INP for this interaction is the time from click to the next visual update—the spinner appearing. If the click handler is clean and the component transition is fast, the spinner might render in ~50ms, which is fine for INP.
But here's the problem: the user keeps doing this. Every time they switch to this tab, they see a spinner. They learn that this tab is slow. They hesitate before clicking it. The perceived performance is poor even if the INP measurement itself looks okay.
How SWR fixes responsiveness
With SWR, the second time the user clicks the Metrics tab:
- User clicks the tab.
- The cached data renders immediately—within the same frame.
- A background revalidation fires silently.
- If new data arrives, the UI updates smoothly.
The INP is near-zero for the interaction: click → cached content rendered → done. There's no spinner, no loading state, and no perceived delay. The UI responds instantly to the user's action, which is exactly what INP measures.
Where this gets critical
INP degrades when interactions trigger expensive synchronous work—state updates that cause large re-renders, layout recalculations, or blocking data fetches that suspend rendering.
Without SWR, a refetch() call in a click handler can trigger a state transition back to loading, causing the entire component tree to re-render with a spinner. With SWR, the state stays stable: data is still the cached result, isFetching flips to true (which you can use for a subtle indicator), and there's no disruptive re-render.
-
Bad approach—full loading state on every tab switch disrupts INP perception:
const { data, isLoading } = useDql(query); // no staleTimeif (isLoading) return <Skeleton />; // Shows on every navigationreturn <DataTable data={data.records} />; -
Good approach—cached data renders instantly, subtle indicator for fetching in the background:
const { data, isLoading, isFetching } = useDql(query, {staleTime: 5 * 60 * 1000,});if (isLoading) return <Skeleton />; // Only on the *very first* loadreturn (<>{isFetching && <RefreshIndicator />} {/* Subtle, non-blocking */}<DataTable data={data.records} /></>);
The key pattern is to use isLoading for the initial skeleton and isFetching for a non-disruptive refresh indicator. Never conditionally unmount your data-rendering tree based on isFetching.
INP impact summary
| Interaction pattern | Without SWR | With SWR |
|---|---|---|
| First visit to a page | Loading → Render | Loading → Render (same) |
| Return visit to a page | Loading → Render again | Instant render from cache |
| Tab switch within a page | Spinner → Wait → Render | Instant render from cache |
| Manual refresh button | Spinner → Wait → Render | Data stays visible, subtle indicator |
refetchInterval tick | Potential flash/re-render | Silent background update |
Every row except the first is an INP improvement. The more frequently users revisit pages and switch tabs in your app, the more SWR improves your INP profile.
Practical patterns
Pattern 1: Dashboard with auto-refresh
A monitoring dashboard that polls Grail every 30 seconds, with cached data rendering instantly on navigation:
import { useDql } from '@dynatrace-sdk/react-hooks';
import { DataTable } from '@dynatrace/strato-components-preview/tables';
import { ProgressBar, Skeleton } from '@dynatrace/strato-components/content';
type CpuRecord = {
'dt.entity.host': string | null;
'timeframe': { start: string; end: string };
'interval': string;
'avg(dt.host.cpu.usage)': (number | null)[];
};
const columns: DataTableColumnDef<CpuRecord>[] = [
{ id: 'host', header: 'Host', accessor: (row) => row['dt.entity.host'] },
{
id: 'sparkline',
header: 'Avg CPU %',
columnType: 'sparkline',
config: { variant: 'area' },
accessor: (row) => {
const intervalMs = Number(row.interval) / 1_000_000;
const startMs = new Date(row.timeframe.start).getTime();
return [
{
name: 'avg CPU %',
unit: 'percent',
datapoints: row['avg(dt.host.cpu.usage)']
.map((value, i) => ({ start: new Date(startMs + intervalMs * i), value }))
.filter((dp): dp is { start: Date; value: number } => dp.value !== null),
},
];
},
},
];
export const CpuUsageDashboard = () => {
const { data, isLoading, isFetching } = useDql<CpuRecord>(
{ query: `timeseries avg(dt.host.cpu.usage), by: { dt.entity.host }, from: now()-2h` },
{ staleTime: 5 * 60 * 1000, refetchInterval: 30 * 1000 },
);
if (isLoading) return <Skeleton />;
return (
<div>
{isFetching && <ProgressBar />}
<DataTable data={data?.records ?? []} columns={columns} fullWidth />
</div>
);
};
export default CpuUsageDashboard;
Pattern 2: Detail view with stable cache
An entity detail page that caches heavily because the user navigates back and forth between list and detail:
import React from 'react';
import { useDql } from '@dynatrace-sdk/react-hooks';
import { Skeleton } from '@dynatrace/strato-components/content';
import { Heading, Text } from '@dynatrace/strato-components/typography';
import { Flex } from '@dynatrace/strato-components/layouts';
type HostRecord = {
id: string;
name: string;
};
export const HostDetail = ({ hostId }: { hostId: string }) => {
const { data, isLoading, isFetching, error } = useDql<HostRecord>(
{ query: `smartscapeNodes HOST | filter id_classic == "${hostId}"` },
{ staleTime: 10 * 60 * 1000 },
);
if (isLoading) return <Skeleton />;
if (error) return <Text color="critical">{error.message}</Text>;
const host = data?.records?.[0];
if (!host) return <Text>No data found for host {hostId}</Text>;
return (
<Flex flexDirection="column" gap={8} style={{ opacity: isFetching ? 0.7 : 1 }}>
<Heading level={3}>{host.name}</Heading>
{/* ... rest of the detail view */}
</Flex>
);
};
The opacity transition is a minimal visual cue that data is refreshing, without destroying the layout or blocking interaction. This keeps the INP clean.
Pattern 3: Filter changes with placeholderData
When the user changes a filter (management zone, timeframe, entity type), a new query fires. Without placeholderData, the UI flashes to a loading state. With it, the previous results stay visible:
import React, { useRef, useState } from 'react';
import type { TypedQueryResult } from '@dynatrace-sdk/react-hooks';
import { useDql } from '@dynatrace-sdk/react-hooks';
import { Skeleton } from '@dynatrace/strato-components/content';
import { Select } from '@dynatrace/strato-components/forms';
import { DataTable } from '@dynatrace/strato-components-preview/tables';
type HostRecord = {
'id': string;
'name': string;
'os.type': string;
};
type Props = {
onSelect?: (hostId: string) => void;
};
export const FilterableHostList = ({ onSelect }: Props) => {
const [osType, setOsType] = useState<string | null>(null);
const prevData = useRef<TypedQueryResult<HostRecord> | undefined>(undefined);
const { data, isLoading, isPlaceholderData, isFetching, error } = useDql<HostRecord>(
{
query: osType
? `smartscapeNodes HOST | filter os.type == "${osType}" | limit 200`
: 'smartscapeNodes HOST | limit 200',
},
{ staleTime: 5 * 60 * 1000, placeholderData: prevData.current },
);
if (data) prevData.current = data;
if (isLoading) return <Skeleton />;
if (error) return <p style={{ color: 'red' }}>{error.message}</p>;
return (
<div>
<Select value={osType} onChange={setOsType}>
<Select.Content>
<Select.Option value={null}>All</Select.Option>
<Select.Option value="OS_TYPE_LINUX">Linux</Select.Option>
<Select.Option value="OS_TYPE_WINDOWS">Windows</Select.Option>
</Select.Content>
</Select>
<div style={{ opacity: isPlaceholderData || isFetching ? 0.6 : 1 }}>
<DataTable
data={data?.records ?? []}
columns={[
{ id: 'id', accessor: 'id', header: 'ID' },
{ id: 'name', accessor: 'name', header: 'Name' },
{ id: 'os', accessor: (row) => row['os.type'], header: 'OS' },
]}
rowId={(row) => row.id}
interactiveRows
onActiveRowChange={(hostId) => hostId && onSelect?.(hostId)}
/>
</div>
</div>
);
};
When the user switches from All Zones to Production, the All Zones data stays visible (dimmed) while the Production query runs. No flash, no spinner, no layout shift. The INP for the filter change is the time to update the select + dim the table—sub-50ms.
When not to use SWR
SWR is not universally appropriate:
- Write operations—don't cache mutation results as stale data. Use
refetch()orforceRefetch()after a write to ensure consistency. - Security-sensitive data—if data visibility should be strictly time-limited (e.g., audit logs for compliance), stale data may be a policy violation. Set
staleTime: 0. - One-shot queries—queries triggered by explicit user action such as selecting Analyze rather than page rendering. Use
enabled: falseandrefetch()instead. - Preview or sampling data—if you use
enablePreview: trueto get partial results, be cautious about caching previews as if they were complete results.
Guidelines
To recap, here are some best practices for applying SWR in your Dynatrace app:
-
Set
staleTimeon everyuseDqlcall—the default (0) means data is stale immediately, causing unnecessary background requests. Be intentional about freshness requirements. -
Distinguish
isLoadingfromisFetching—show skeletons forisLoading(first load). Show subtle indicators forisFetching(background refresh). Never useisFetchingto unmount your content tree. -
Use
placeholderDatafor filter/parameter changes—when query parameters change, keep previous results visible while the new query runs to avoid flash-of-loading-state. -
Combine
staleTimeandrefetchIntervaldeliberately—staleTimecontrols cache freshness for navigation.refetchIntervalcontrols polling frequency. Set both for monitoring views. -
Don't over-cache—a 10-minute
staleTimeis great for entity metadata. It's wrong for error logs in an active incident. Match your cache duration to data volatility. -
Use
forceRefetchfor user-initiated refreshes—when the user clicks a "Refresh" button, they expect fresh data regardless of cache state.forceRefetchignoresstaleTime. -
Keep your INP under 200ms—SWR eliminates most interaction-triggered loading states after the first visit. If your INP is still high, look at component rendering cost, not data fetching.