Skip to main content

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:

  1. Cache hit—returns cached data instantly. The user sees content immediately.
  2. Background revalidation—fires a network request to get fresh data. The user doesn't notice.
  3. 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:

StepWithout SWRWith SWR
User navigates to a pageLoading spinnerCached data renders instantly
Network request firesUser waitsHappens invisibly in the background
Data arrivesContent rendersUI 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:

  • isLoading is true only 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.
  • isFetching is true whenever 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 typeSuggested staleTimeReasoning
Entity metadata (hosts, services, process groups)5–10 minutesChanges infrequently, expensive queries
Configuration / settings5–10 minutesRarely changes during a session
Metrics (timeseries)30–60 secondsNew data points arrive regularly
Logs15–30 secondsActive systems produce logs continuously
Real-time events0 (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:

  1. User clicks the Metrics tab.
  2. The app triggers a DQL query against Grail.
  3. The component renders a loading spinner.
  4. The query takes between 2 and 4 seconds.
  5. 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:

  1. User clicks the tab.
  2. The cached data renders immediately—within the same frame.
  3. A background revalidation fires silently.
  4. 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 staleTime

    if (isLoading) return <Skeleton />; // Shows on every navigation
    return <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* load
    return (
    <>
    {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 patternWithout SWRWith SWR
First visit to a pageLoading → RenderLoading → Render (same)
Return visit to a pageLoading → Render againInstant render from cache
Tab switch within a pageSpinner → Wait → RenderInstant render from cache
Manual refresh buttonSpinner → Wait → RenderData stays visible, subtle indicator
refetchInterval tickPotential flash/re-renderSilent 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() or forceRefetch() 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: false and refetch() instead.
  • Preview or sampling data—if you use enablePreview: true to 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:

  1. Set staleTime on every useDql call—the default (0) means data is stale immediately, causing unnecessary background requests. Be intentional about freshness requirements.

  2. Distinguish isLoading from isFetching—show skeletons for isLoading (first load). Show subtle indicators for isFetching (background refresh). Never use isFetching to unmount your content tree.

  3. Use placeholderData for filter/parameter changes—when query parameters change, keep previous results visible while the new query runs to avoid flash-of-loading-state.

  4. Combine staleTime and refetchInterval deliberatelystaleTime controls cache freshness for navigation. refetchInterval controls polling frequency. Set both for monitoring views.

  5. Don't over-cache—a 10-minute staleTime is great for entity metadata. It's wrong for error logs in an active incident. Match your cache duration to data volatility.

  6. Use forceRefetch for user-initiated refreshes—when the user clicks a "Refresh" button, they expect fresh data regardless of cache state. forceRefetch ignores staleTime.

  7. 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.

Still have questions?
Find answers in the Dynatrace Community