SWR Caching Strategies for Real-Time Applications
Implementing effective SWR caching with revalidation, mutation, and cache provider configuration for applications with real-time data requirements.
Introduction
SWR (stale-while-revalidate) is a React Hooks library for data fetching that implements the HTTP cache invalidation strategy. Its name comes from the HTTP cache invalidation strategy where the client serves stale data while fetching fresh data in the background. For real-time applications, proper SWR configuration is essential to balance data freshness with performance.
After building several real-time dashboards with SWR, I learned that the default configuration is insufficient for applications where data changes frequently. This post covers the caching strategies that prevent stale data issues while maintaining acceptable performance.
Environment
node --version
# v20.11.0
npm list swr react
# swr@2.2.4
# react@18.2.0The application is a real-time analytics dashboard:
analytics/
βββ src/
β βββ hooks/
β β βββ useMetrics.ts
β β βββ useEvents.ts
β β βββ useRealtime.ts
β βββ lib/
β β βββ swr-config.ts
β β βββ fetcher.ts
β βββ components/
β βββ Dashboard.tsx
β βββ LiveChart.tsxProblem
The default SWR configuration caused three issues in the real-time dashboard. First, the metrics displayed data that was up to 30 seconds old, which was unacceptable for a live monitoring tool. Second, when multiple components requested the same data, each component made its own fetch request instead of sharing a single request.
The third issue was more subtle. When the WebSocket received an update and the component called mutate(), SWR revalidated the data from the server. But if the server had not yet processed the update, the revalidation returned stale data, effectively undoing the optimistic update:
// This optimistic update was immediately overwritten
const handleUpdate = async (newMetric: Metric) => {
await mutate(
"/api/metrics",
[...currentMetrics, newMetric],
false // revalidate: false
);
// But some component triggered revalidation
// and the old data came back
// The optimistic update was lost
};The fetching deduplication issue appeared when the dashboard mounted multiple chart components that all needed the same metrics data. Each chart was making its own fetch request:
// These three components each fetch /api/metrics independently
function Dashboard() {
return (
<>
{/* Fetches /api/metrics */}
{/* Fetches /api/metrics */}
{/* Fetches /api/metrics */}
>
);
}Analysis
SWR's default dedupingInterval is 2000ms. During this window, identical requests are deduplicated. However, when components mount at different times (like when a tab is switched), the deduplication window may have expired, causing duplicate requests.
The revalidation timing is controlled by refreshInterval, revalidateOnFocus, and revalidateOnReconnect. For real-time applications, the default values are too conservative. The refreshInterval defaults to 0 (disabled), which means the data is only fetched once unless explicitly revalidated.
The optimistic update issue is caused by the default revalidation behavior. When mutate() is called, SWR fetches fresh data from the server to ensure consistency. If the server has not yet processed the mutation, the response contains stale data that overwrites the optimistic update.
Solution
Configure a global SWR provider with optimized settings:
// lib/swr-config.ts
import { SWRConfig } from "swr";
import { fetcher } from "./fetcher";
export const swrConfig: SWRConfig["value"] = {
fetcher,
// Deduplicate requests within 5 seconds
dedupingInterval: 5000,
// Revalidate when window gains focus
revalidateOnFocus: true,
// Revalidate when network reconnects
revalidateOnReconnect: true,
// Keep previous data while fetching
keepPreviousData: true,
// Error retry configuration
errorRetryCount: 3,
errorRetryInterval: 1000,
// Focus throttle interval
focusThrottleInterval: 5000,
// Loading throttle interval
loadingTimeout: 3000,
// Custom error handler
onError: (err, key) => {
if (err.status === 401) {
// Handle unauthorized
window.location.href = "/login";
}
},
};
// app/layout.tsx
import { SWRConfig } from "swr";
import { swrConfig } from "@/lib/swr-config";
export default function RootLayout({ children }) {
return (
{children}
);
}Create optimized hooks with proper caching:
// hooks/useMetrics.ts
import useSWR, { useSWRConfig } from "swr";
import { useCallback } from "react";
const METRICS_KEY = "/api/metrics";
export function useMetrics() {
const { data, error, isLoading, isValidating } = useSWR(METRICS_KEY, {
// Revalidate every 5 seconds for real-time data
refreshInterval: 5000,
// Deduplicate within 1 second for rapid updates
dedupingInterval: 1000,
// Keep previous data during revalidation
keepPreviousData: true,
// Populate cache for faster initial load
fallbackData: [],
});
return {
metrics: data ?? [],
error,
isLoading,
isValidating,
};
}
// Optimistic mutation hook
export function useMetricsMutation() {
const { mutate } = useSWRConfig();
const addMetric = useCallback(
async (newMetric: Metric) => {
await mutate(
METRICS_KEY,
async (currentMetrics: Metric[]) => {
// API call
const response = await fetch("/api/metrics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newMetric),
});
if (!response.ok) {
throw new Error("Failed to add metric");
}
// Return updated data
return [...currentMetrics, newMetric];
},
{
// Don't revalidate immediately - trust the optimistic update
revalidate: false,
// Rollback on error
populateCache: true,
}
);
},
[mutate]
);
const deleteMetric = useCallback(
async (id: string) => {
await mutate(
METRICS_KEY,
async (currentMetrics: Metric[]) => {
const response = await fetch(`/api/metrics/${id}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Failed to delete metric");
}
return currentMetrics.filter((m) => m.id !== id);
},
{
revalidate: false,
populateCache: true,
}
);
},
[mutate]
);
return { addMetric, deleteMetric };
}Implement WebSocket integration with SWR:
// hooks/useRealtime.ts
import { useEffect } from "react";
import { useSWRConfig } from "swr";
export function useRealtime(key: string) {
const { mutate } = useSWRConfig();
useEffect(() => {
const ws = new WebSocket(process.env.NEXT_PUBLIC_WS_URL!);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "update" && data.key === key) {
// Update cache with real-time data
mutate(key, data.payload, {
revalidate: false,
optimisticData: data.payload,
});
}
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
return () => ws.close();
}, [key, mutate]);
}
// Usage in component
function LiveChart() {
useRealtime("/api/metrics");
const { metrics } = useMetrics();
return ;
}Create a custom cache provider for persistence:
// lib/cache-provider.ts
import { Cache } from "swr";
// localStorage-based cache provider
function localStorageProvider(): Map {
// Only on client side
if (typeof window === "undefined") {
return new Map();
}
const map = new Map(
JSON.parse(localStorage.getItem("app-cache") || "[]")
);
// Save to localStorage before unload
window.addEventListener("beforeunload", () => {
const appCache = JSON.stringify(Array.from(map.entries()));
localStorage.setItem("app-cache", appCache);
});
return map;
}
// In SWR config
{children}
Lessons Learned
Set
refreshIntervalfor real-time data. The default of 0 (disabled) is only suitable for static data. For dashboards, 5-10 seconds is a good balance.Use
revalidate: falsein optimistic mutations to prevent the server response from overwriting the optimistic update.Deduplication interval should match your update frequency. If data updates every second, set
dedupingIntervalto 1000ms to prevent duplicate requests.The localStorage cache provider provides a better initial loading experience by serving cached data immediately while fresh data loads in the background.
WebSocket integration should update the SWR cache directly instead of making additional API calls. This ensures real-time consistency without extra network requests.
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.