SWR Cache Memory and Provider Scoping

SWR makes data fetching pleasant, and after an afternoon of browsing a catalogue the tab holds every product, search result page and infinite-scroll page the user ever loaded, because SWR’s default cache never evicts anything. This guide from State Management and Client Cache Memory Leaks, in Framework-Specific Memory Optimization, explains how SWR stores data, why the default grows with session length, and how custom cache providers and scoping bound it.

Symptom Root Cause Immediate Action Measurable Impact
Memory grows with every distinct key fetched Default global cache is a plain Map with no eviction Provide a bounded (LRU) cache via SWRConfig Cache size capped
Infinite lists hold hundreds of pages useSWRInfinite keeps all loaded pages Cap pages; reset size when leaving the list Memory bounded per list
Feature data stays after the feature closes All features share one global cache Scope a provider to the feature’s subtree Cache discarded with the subtree
Previous user’s data after logout Global cache survives logout Clear keys on logout, or remount the provider Memory and privacy reset
Keys include volatile values (timestamps, objects) Every render creates a new key Build stable, serialisable keys Far fewer entries

Root Cause: A Cache With No Eviction Policy

SWR keeps fetched data in a cache provider — by default a single Map created once for the app. Each key (a URL string, or an array serialised into a key) maps to its data, error and metadata. Components using useSWR(key, fetcher) read from and write to that map. When the last component using a key unmounts, SWR stops revalidating it — but the entry stays in the map. The default provider has no size limit and no time-based eviction, so the cache grows with the number of distinct keys ever fetched, a pattern discussed generally in memory-safe caching patterns.

For many apps that is fine: a dashboard with twenty endpoints has twenty entries. It becomes a problem when keys are high-cardinality — product detail pages by ID, search queries, pagination, filters — or when payloads are large. useSWRInfinite compounds it by storing every page of an infinite list under its key; a user who scrolls through 300 pages has 300 pages in memory, even after navigating away.

SWR’s answer is provider scoping. <SWRConfig value={{ provider: () => new Map() }}> gives the subtree its own cache instance, created when the provider mounts and discarded when it unmounts. That lets you tie cache lifetime to a feature, a route or a user session. And because provider can return any object implementing the Map interface used by SWR (get, set, delete, keys), you can supply a bounded LRU so that even a long-lived global cache has a ceiling. Unlike libraries with built-in garbage-collection timers — compare RTK Query keepUnusedDataFor — SWR leaves the eviction policy entirely to you.

Global Map versus scoped and bounded providers Left: the default global Map holds keys from the catalogue, search and account features, and every key stays for the session. Right: the root provider is a bounded LRU for shared data, while the catalogue feature has its own scoped provider created on mount and discarded on unmount, so catalogue keys disappear when the user leaves the catalogue. Default: one global Map /api/products/1 … /api/products/940 /api/search?q=… (every query) $inf$/api/feed (300 pages) /api/account never evicted during the session Root provider: LRU (max 500) shared data, bounded Catalogue provider: new Map() created on mount of the catalogue route discarded on unmount → keys released

Step-by-Step Fix

  1. Measure the cache. Access the cache with useSWRConfig().cache in a debug component and log [...cache.keys()].length over a session, or take heap snapshots and check the provider Map’s retained size. Verification: you know how many keys and how much memory accumulate.
  2. Stabilise keys. Ensure keys are strings or arrays of primitives built from the fields that matter; avoid timestamps, new objects or unsorted filter arrays in keys. Verification: re-rendering does not create new keys.
  3. Install a bounded provider at the root. Pass provider: () => new LruMapProvider(500) to the top-level SWRConfig. Verification: the key count never exceeds the bound, and evicted keys refetch when needed.
  4. Scope feature caches. Wrap high-cardinality features (catalogue, search) in their own SWRConfig with provider: () => new Map(), so their data is discarded when the feature unmounts. Verification: leaving the feature removes its keys from memory.
  5. Cap infinite lists. Limit setSize growth (for example, keep at most 20 pages) and reset the size when leaving the list. Verification: the infinite key holds a bounded number of pages.
  6. Clear on logout. Clear all keys without revalidation, or remount the root provider keyed by user ID. Verification: no data from the previous user remains in the cache.
Cache keys over a two-hour session With the default Map provider the number of cache keys grows to about 3,100 over two hours of browsing a catalogue and searching. With a 500-key LRU provider the count rises to 500 and stays there, with evicted keys refetched on demand. 3,100 0 default Map provider LRU provider (max 500) minutes of browsing (0 → 120)

Command and Code Reference

Use case: a bounded LRU cache provider. SWR uses the Map-like methods get, set, delete and keys.

// lru-provider.js
export class LruMapProvider {
  #map = new Map();
  constructor(max = 500) { this.max = max; }
  get(key) {
    if (!this.#map.has(key)) return undefined;
    const v = this.#map.get(key);
    this.#map.delete(key); this.#map.set(key, v);    // refresh recency
    return v;
  }
  set(key, value) {
    this.#map.delete(key);
    this.#map.set(key, value);
    if (this.#map.size > this.max) this.#map.delete(this.#map.keys().next().value);
  }
  delete(key) { return this.#map.delete(key); }
  keys() { return this.#map.keys(); }
}

// App.jsx
<SWRConfig value={{ provider: () => new LruMapProvider(500) }}>
  <App />
</SWRConfig>

Use case: a feature-scoped cache that disappears with the feature.

function CatalogueRoute() {
  // New cache per mount of this route; released when the route unmounts
  return (
    <SWRConfig value={{ provider: () => new Map() }}>
      <Catalogue />
    </SWRConfig>
  );
}

Use case: clear the cache on logout and cap infinite pages.

const { mutate } = useSWRConfig();
async function logout() {
  await api.logout();
  // Remove every key's data without triggering refetches
  await mutate(() => true, undefined, { revalidate: false });
}

// Infinite list: never hold more than 20 pages
const { data, size, setSize } = useSWRInfinite(getKey, fetcher);
const loadMore = () => setSize(Math.min(size + 1, 20));

Verification and Regression Prevention

Verify over a long browsing session: the root cache’s key count stays at or below its bound, feature caches vanish when features unmount (heap snapshots no longer contain their data), infinite lists never exceed the page cap, and logout leaves the cache empty. Watch refetch rates after introducing bounds — an LRU that is too small causes repeated fetches for recently used keys, which you will see as network churn.

Keep a debug overlay or test hook that reports cache key counts, and add an end-to-end test that browses many items and asserts the count stays bounded. When evaluating whether to cache at all, remember that SWR’s revalidation keeps data fresh while it is displayed; for data that is not reused, a scoped provider is usually better than a large global cache. For TanStack Query’s built-in equivalent, see TanStack Query cache garbage collection settings.

Four bounds to confirm in a long session Over a long browsing session, the root cache key count stays at or below its bound, feature-scoped caches disappear when their features unmount, infinite lists never exceed the page cap, and logout leaves the cache empty. Watch refetch rates: an LRU that is too small causes network churn. Long browsing session Root cache Key count stays at or below the LRU bound. Feature providers Scoped caches vanish when the feature unmounts. Infinite lists Loaded pages never exceed the page cap. Logout Cache is empty; no previous user data in snapshots.

Edge Cases and Gotchas

Evicted keys still used on screen

If a key is evicted while a component still displays it, the next render reads no cached data and SWR fetches again. Size the LRU above the number of keys visible at once, or scope high-churn data to its own provider.

Provider functions must be stable

provider: () => new Map() inside a component that re-renders would create a new cache each time the SWRConfig remounts. Define providers at module level or ensure the SWRConfig element is stable.

Server rendering and fallback data

fallback data passed through SWRConfig for server rendering is kept in the configuration object. Large fallbacks stay in memory as long as that configuration is mounted; pass only what the page needs.

Persisted caches

Some setups persist the cache to localStorage on unload and restore it on load. An unbounded cache then also grows storage and slows startup. Bound the cache before persisting it.

Frequently Asked Questions

Does SWR clear its cache automatically?

No. The default provider is a Map that keeps every key until the page unloads or you delete it. SWR stops revalidating keys that are not in use, but their data remains cached.

How do I limit SWR cache size?

Supply a custom provider through SWRConfig that implements the Map-like interface with a bound — for example an LRU that evicts the least recently used key when a maximum is exceeded — or scope caches to features so they are discarded on unmount.

How do I clear SWR data on logout?

Call mutate(() => true, undefined, { revalidate: false }) from useSWRConfig() to clear every key without refetching, or remount the root SWRConfig provider (for example by keying it on the user ID).

Why does useSWRInfinite use so much memory?

It stores every loaded page under the list’s key. Long scrolling sessions accumulate hundreds of pages. Cap the number of pages you allow, reset the size when leaving the list, and consider virtualising the rendered rows.

Can I combine SWR’s revalidation with a small cache?

Yes. Revalidation keeps displayed data fresh regardless of cache size, and a bounded cache only affects how often data must be refetched after it has been evicted. Choose the bound so that the keys visible at once, plus the few the user is likely to return to, fit comfortably.

Are scoped providers bad for performance?

They trade cross-feature reuse for bounded memory. Data shared across features should live in the root cache; data used only inside one feature benefits from a scoped provider, which also avoids keeping it after the feature closes.