Testing SWR Revalidation Behaviour

SWR’s name is its strategy: stale-while-revalidate. A component gets cached data immediately, SWR fetches in the background, and the component updates when fresh data arrives. Revalidation then happens again on mount, on window focus, on reconnect and optionally on an interval. That model makes interfaces feel instant and makes tests surprisingly subtle — SWR’s global cache persists between tests, its request deduplication hides second requests, and its revalidation triggers fire from events tests do not expect. This guide covers giving each test its own cache provider, configuring SWR for tests, and verifying each revalidation trigger deliberately: mount, focus, interval, and manual mutate. It targets SWR 2.x with Vitest and sits under data fetching and cache testing.

Root Cause Analysis

SWR’s default cache is a single global Map. Unlike a query client that tests create and discard, it lives for the whole module and therefore for every test in a file. A test that loads /api/user leaves the response in the cache, and the next test that renders the same key sees it instantly — whether or not the next test’s handler would have returned the same thing.

Deduplication compounds this. SWR ignores identical requests made within a two-second window, which is the right behaviour for a page where three components ask for the same user, and wrong in a test that renders, unmounts and renders again expecting a fresh request. The second request never happens, and the test asserts on the first response.

Revalidation triggers are the third issue. jsdom dispatches focus and visibility events during some interactions, and SWR responds by revalidating. A test that clicks a button can therefore trigger a background fetch it never set up a handler for, which with onUnhandledRequest: 'error' fails the test, and without it silently fetches from nowhere. Each trigger should either be disabled or tested on purpose.

Stale-while-revalidate, step by step A component renders cached data immediately, SWR revalidates in the background, and when the fresh response arrives the component re-renders; triggers for revalidation include mount, focus, reconnect, an interval and a manual mutate call. render stale from the cache revalidate in the background render fresh on arrival triggers: mount · focus · reconnect · interval · mutate() each is either disabled in tests or exercised by one deliberate test
The model is simple; the difficulty is that its triggers fire whether or not a test expects them.

Reproducible Setup

A component that shows the current user’s notifications, refreshing every thirty seconds.

// src/notifications/NotificationBell.tsx
import useSWR from 'swr';

const fetcher = (url: string) => fetch(url).then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json(); });

export function NotificationBell() {
  const { data, error, isValidating, mutate } = useSWR<{ unread: number }>('/api/notifications', fetcher, { refreshInterval: 30_000 });
  if (error) return <button onClick={() => mutate()}>Notifications unavailable — retry</button>;
  return (
    <button aria-label={`Notifications, ${data?.unread ?? 0} unread`} aria-busy={isValidating}>
      🔔 {data?.unread ?? '…'}
    </button>
  );
}

Implementation

Step 1 — Give each render its own cache provider. SWRConfig’s provider option accepts a function returning a new Map, which isolates the cache per test.

// test/render-with-swr.tsx
import { SWRConfig } from 'swr';
import { render } from '@testing-library/react';
import type { ReactElement } from 'react';

export function renderWithSWR(ui: ReactElement, options: Record<string, unknown> = {}) {
  return render(
    <SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0, revalidateOnFocus: false, shouldRetryOnError: false, ...options }}>
      {ui}
    </SWRConfig>,
  );
}

dedupingInterval: 0 makes every requested revalidation actually request; revalidateOnFocus: false stops incidental focus events from fetching; shouldRetryOnError: false makes errors surface immediately.

Every one of these options changes behaviour users rely on in production, which is why they belong in the test helper rather than in the application’s global configuration. The application keeps its real defaults — deduplication, focus revalidation, retries — and the tests that care about any of them re-enable it explicitly, as Step 4 does for focus. That keeps the behaviour visible: a reader can see which tests exercise which trigger.

Step 2 — Test the first load and the stale-then-fresh update. Seed the provider with stale data and let the handler return fresh data.

test('shows cached count immediately, then the fresh count', async () => {
  server.use(http.get('/api/notifications', () => HttpResponse.json({ unread: 5 })));
  renderWithSWR(<NotificationBell />, {
    provider: () => new Map([['/api/notifications', { data: { unread: 2 } }]]),
  });

  expect(screen.getByRole('button', { name: 'Notifications, 2 unread' })).toBeInTheDocument();
  expect(await screen.findByRole('button', { name: 'Notifications, 5 unread' })).toBeInTheDocument();
});

The two assertions in sequence are the whole point of SWR, and a test that checked only the final count would miss half of it. The stale value appearing first is what makes the interface feel instant; the fresh value replacing it is what keeps it correct. A regression in either direction — a blank loading state where cached data should appear, or stale data that never refreshes — fails one of the two lines specifically.

Step 3 — Test interval polling with fake timers. Advance past the interval and count requests; no real waiting.

test('polls every thirty seconds', async () => {
  vi.useFakeTimers({ shouldAdvanceTime: true });
  let unread = 1;
  let calls = 0;
  server.use(http.get('/api/notifications', () => { calls++; return HttpResponse.json({ unread }); }));
  renderWithSWR(<NotificationBell />);
  await screen.findByRole('button', { name: 'Notifications, 1 unread' });

  unread = 4;
  await act(() => vi.advanceTimersByTimeAsync(30_000));
  expect(await screen.findByRole('button', { name: 'Notifications, 4 unread' })).toBeInTheDocument();
  expect(calls).toBe(2);
  vi.useRealTimers();
});

Step 4 — Test focus revalidation deliberately. Enable it for this test only, dispatch the event SWR listens for, and assert a new request.

test('refreshes when the user returns to the tab', async () => {
  let calls = 0;
  server.use(http.get('/api/notifications', () => { calls++; return HttpResponse.json({ unread: calls }); }));
  renderWithSWR(<NotificationBell />, { revalidateOnFocus: true, focusThrottleInterval: 0 });
  await screen.findByRole('button', { name: 'Notifications, 1 unread' });

  act(() => { window.dispatchEvent(new Event('focus')); });
  expect(await screen.findByRole('button', { name: 'Notifications, 2 unread' })).toBeInTheDocument();
});
Test-time SWR configuration and what each option prevents A new Map provider prevents cache leakage between tests, dedupingInterval zero prevents skipped requests, revalidateOnFocus false prevents surprise fetches, and shouldRetryOnError false prevents slow error tests. Option Prevents provider: () => new Map() data leaking between tests dedupingInterval: 0 second requests silently skipped revalidateOnFocus: false fetches from incidental focus events shouldRetryOnError: false error tests waiting on retry backoff
Defaults that serve users well each create a specific test problem; the provider option fixes the worst of them.

Setting focusThrottleInterval to zero matters here: SWR throttles focus revalidation to avoid a burst of requests when a user switches tabs rapidly, and with the default a focus event shortly after mount is ignored. Turning the throttle off lets the test produce the event and observe the revalidation without waiting out the throttle window.

Step 5 — Test manual revalidation through the interface. The retry button calls mutate(); a failing then succeeding handler proves recovery.

test('recovers when the user retries after an error', async () => {
  server.use(http.get('/api/notifications', () => new HttpResponse(null, { status: 503 })));
  const user = userEvent.setup();
  renderWithSWR(<NotificationBell />);
  const retry = await screen.findByRole('button', { name: /unavailable — retry/ });

  server.use(http.get('/api/notifications', () => HttpResponse.json({ unread: 3 })));
  await user.click(retry);
  expect(await screen.findByRole('button', { name: 'Notifications, 3 unread' })).toBeInTheDocument();
});

Step 6 — Test bound mutate for optimistic local updates. Marking notifications read should update the count immediately, then reconcile with the server.

test('marking all read updates the bell before the server responds', async () => {
  server.use(
    http.get('/api/notifications', () => HttpResponse.json({ unread: 0 })),
    http.post('/api/notifications/read', async () => { await delay(100); return HttpResponse.json({ ok: true }); }),
  );
  const user = userEvent.setup();
  renderWithSWR(<NotificationsPanel />, { provider: () => new Map([['/api/notifications', { data: { unread: 6 } }]]) });
  await user.click(screen.getByRole('button', { name: 'Mark all as read' }));
  expect(screen.getByRole('button', { name: 'Notifications, 0 unread' })).toBeInTheDocument();
});

Optimistic local mutation is where SWR tests most often stop too early. The instant update is only half the behaviour; the other half is what happens when the server disagrees. Pair this test with one where the POST fails and assert the count returns to its previous value, using the rollback options that SWR’s mutate provides — the pattern is covered fully in the optimistic updates guide.

Verification

Confirm isolation by removing the provider option and running the file in shuffled order; cached counts from one test should appear in another. With the provider restored, every order passes.

npx vitest run src/notifications --sequence.shuffle --reporter=verbose
# ✓ shows cached count immediately, then the fresh count
# ✓ polls every thirty seconds
# ✓ refreshes when the user returns to the tab
# ✓ recovers when the user retries after an error

Then confirm the polling test counts correctly by changing the interval to fifteen seconds in the component. The request count should rise to three, proving the test pins the interval rather than merely observing that polling happens.

One deliberate test per revalidation trigger Mount revalidation is tested with a seeded cache, interval revalidation with fake timers, focus revalidation by dispatching a focus event with the option enabled, and manual revalidation through the retry control. mount seeded cache interval fake timers focus enabled, dispatched manual mutate via retry
Disabled by default in tests, each trigger is switched on exactly where it is the behaviour being verified.

Troubleshooting

Symptom: a test shows data it never requested. Diagnosis: the global cache holds another test’s response. Fix: render inside SWRConfig with provider: () => new Map() for every test, via the helper.

Symptom: a second render makes no request. Diagnosis: deduplication within the default two-second window. Fix: set dedupingInterval: 0 in tests, as the helper does.

Symptom: an unhandled-request error appears after a click. Diagnosis: the click caused a focus event that triggered revalidation. Fix: keep revalidateOnFocus: false by default and enable it only in the test that is about it.

Symptom: the polling test hangs. Diagnosis: fake timers without shouldAdvanceTime stall promises that SWR awaits internally. Fix: use advanceTimersByTimeAsync inside act, and enable shouldAdvanceTime so microtask-driven work continues.

FAQ

Is SWR harder to test than TanStack Query?

Only because its cache is global by default. Once each test renders inside its own provider, the two are equally straightforward, and the same principles — fresh cache, retries off, network intercepted — apply to both; compare testing React Query hooks with a fresh cache.

Should I use mutate from useSWRConfig in tests?

To seed or reset the cache from outside a component, the provider option is cleaner. Global mutate is useful when the test is about cross-component invalidation — one component’s action refreshing another’s data — which is behaviour worth asserting.

How do I test useSWRInfinite?

Render the component, wait for the first page, trigger setSize(2) through the interface’s “Load more”, and assert on both the rendered items and the second request’s cursor. Keep the provider isolated, since infinite keys are easy to collide across tests.

What about SSR fallback data?

Pass fallback through SWRConfig exactly as the application’s page does, and assert the component renders it immediately before revalidating — the same pattern as the seeded provider in Step 2.