RTK Query keepUnusedDataFor and Cache Lifetime

Redux DevTools shows the api slice’s queries object with thousands of entries — one per search term, per page, per record ever viewed — and the store’s memory keeps climbing through the session. This guide from State Management and Client Cache Memory Leaks, part of Framework-Specific Memory Optimization, explains how RTK Query decides when to remove cached data, the three common ways entries become immortal, and how to set lifetimes deliberately.

Symptom Root Cause Immediate Action Measurable Impact
api.queries keeps entries for views long closed Entries still have a subscription (manual initiate() never unsubscribed) Call .unsubscribe() on the returned subscription Entries removed after keepUnusedDataFor
Entries never expire even without subscribers keepUnusedDataFor: Infinity set globally or on an endpoint Use finite lifetimes; reserve long ones for small reference data Cache shrinks after navigation
One entry per keystroke in search Query arg changes on every character Debounce input; lower keepUnusedDataFor for search endpoints Far fewer entries, shorter-lived
Large payloads kept for 60 s after leaving a view Default keepUnusedDataFor of 60 seconds Shorten for heavy endpoints Big data released sooner
Cache persists across logout Cache is part of the Redux store Dispatch api.util.resetApiState() on logout Memory and privacy reset

Root Cause: Reference Counting Plus a Grace Period

RTK Query stores each distinct combination of endpoint and argument as a cache entry in the api slice of your Redux store. Entries are reference counted by subscriptions: each useQuery hook with a given argument holds a subscription while mounted, and manual dispatch(api.endpoints.x.initiate(arg)) calls create one too. When an entry’s subscription count drops to zero, RTK Query starts a timer of keepUnusedDataFor seconds (60 by default); if nothing subscribes again before it fires, the entry — data, error, request metadata — is removed from the store. This is the Redux Toolkit equivalent of the garbage-collection timer described for another library in TanStack Query cache garbage collection settings.

That design is memory-safe by default, and there are three common ways to defeat it. Unreleased manual subscriptions: initiate() returns a promise-like object with unsubscribe(). Code that prefetches or loads data in thunks, route loaders or effects with dispatch(endpoint.initiate(arg)) and never calls unsubscribe() leaves the count at one forever, so the entry never becomes unused. (The prefetch utilities and initiate(arg, { subscribe: false }) avoid creating a lasting subscription.) Infinite lifetimes: setting keepUnusedDataFor: Infinity (or a very large number) on the API or endpoint to “make navigation instant” keeps every entry ever created. High-cardinality arguments: search-as-you-type, infinite scrolling with page arguments and per-record detail queries create a new entry per distinct argument; with generous lifetimes, the number of entries tracks everything the user did.

Because entries live in the Redux store, they also carry the store’s overheads: they appear in every state snapshot, serialisation (for persistence or DevTools) and selector computation. Redux DevTools itself retains history of actions and states in development, which inflates memory further — a measurement concern also covered in Redux store memory growth and normalized state.

How a cache entry is removed — or not Top row: a useQuery hook subscribes when a component mounts, the count goes to zero on unmount, a keepUnusedDataFor timer of 60 seconds starts, and the entry is removed when it fires. Bottom row: a manual initiate call adds a subscription that is never unsubscribed, so the count never reaches zero, the timer never starts and the entry stays in the store forever. useQuery mounts subscriptions: 1 component unmounts subscriptions: 0 timer running keepUnusedDataFor 60 s entry removed dispatch(initiate) never unsubscribed subscriptions ≥ 1 forever → timer never starts entry and its data stay in the Redux store for the whole session

Step-by-Step Fix

  1. Inspect the cache. In Redux DevTools, open the state tree for your API slice and count entries in queries, or log Object.keys(store.getState().api.queries).length over a session. Verification: you know whether entries accumulate with navigation.
  2. Check subscriptions on stale entries. Look at api.subscriptions (or the per-entry subscription data in DevTools) for entries belonging to closed views. Verification: entries that should be unused still have subscribers — pointing to manual initiate() calls.
  3. Release manual subscriptions. Store the result of dispatch(endpoint.initiate(arg)) and call .unsubscribe() when the consumer is done, or use prefetch/{ subscribe: false } for fire-and-forget loading. Verification: after leaving the view and waiting keepUnusedDataFor, those entries disappear.
  4. Set lifetimes per endpoint. Remove global Infinity; give heavy or high-cardinality endpoints short keepUnusedDataFor values (for example 5–15 seconds) and small reference data longer ones. Verification: the cache shrinks after navigation at the expected pace.
  5. Reduce argument cardinality. Debounce search inputs, normalise arguments (sorted filters, trimmed strings), and use skip for incomplete input. Verification: entries per search session drop from one per keystroke to one per settled query.
  6. Reset on logout. Dispatch api.util.resetApiState() when the user logs out. Verification: queries is empty after logout.
Cache entries over a 30-minute session With keepUnusedDataFor set to Infinity and manual initiate calls never unsubscribed, cached query entries grow to about 2,400 over thirty minutes. With finite lifetimes, unsubscribe on manual loads and debounced search, entries fluctuate between about 20 and 60. 2,400 0 Infinity + unreleased initiate() finite lifetimes, unsubscribe, debounced search session minutes (0 → 30)

Command and Code Reference

Use case: per-endpoint lifetimes.

export const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  keepUnusedDataFor: 30,                                   // default for all endpoints (seconds)
  endpoints: (build) => ({
    countries: build.query<Country[], void>({
      query: () => 'countries',
      keepUnusedDataFor: 60 * 60,                          // small reference data: keep longer
    }),
    search: build.query<Result[], string>({
      query: (q) => `search?q=${encodeURIComponent(q)}`,
      keepUnusedDataFor: 5,                                // high-cardinality: expire quickly
    }),
    report: build.query<Report, string>({
      query: (id) => `reports/${id}`,
      keepUnusedDataFor: 10,                               // large payloads: release soon
    }),
  }),
});

Use case: manual loading without leaking a subscription.

// In a route loader or thunk
export async function loadReport(dispatch: AppDispatch, id: string) {
  const sub = dispatch(api.endpoints.report.initiate(id));
  try {
    return await sub.unwrap();                             // wait for the data
  } finally {
    sub.unsubscribe();                                     // allow keepUnusedDataFor to start
  }
}

// Or, for prefetching only:
dispatch(api.util.prefetch('report', id, { force: false }));

Use case: debounced search that does not create an entry per keystroke.

function Search() {
  const [text, setText] = useState('');
  const query = useDebouncedValue(text.trim(), 300);        // settle before querying
  const { data } = api.useSearchQuery(query, { skip: query.length < 2 });
  return <SearchBox value={text} onChange={setText} results={data} />;
}

Verification and Regression Prevention

Verify over a realistic session: the number of entries in api.queries should stay within a small range after navigation settles, entries for closed views should disappear roughly keepUnusedDataFor seconds after the view unmounts, and heap snapshots should show no large payloads from closed views after that period. Measure in production builds; Redux DevTools’ action and state history inflates memory in development.

Add a test that navigates through many records and asserts that the query count returns under a threshold after the lifetime expires (using fake timers in unit tests or waiting in end-to-end tests). In code review, flag keepUnusedDataFor: Infinity, initiate( without a matching unsubscribe(), and query hooks whose arguments come straight from input events without debouncing.

Query entries across a session Browsing many records in a production build, a very long keepUnusedDataFor lets api.queries accumulate an entry per record visited. With a value matched to how soon users return, entries for closed views expire about keepUnusedDataFor seconds after unmount and the count settles in a small range. entries records browsed keepUnusedDataFor very long matched to return time Measure in production builds: Redux DevTools history inflates memory in development.

Edge Cases and Gotchas

refetchOnMountOrArgChange does not shorten lifetimes

It controls refetching when a subscriber mounts, not how long unused data stays. Lower keepUnusedDataFor to reduce memory.

Infinite scrolling and merge

Endpoints that merge pages into one cache entry (using serializeQueryArgs and merge) avoid one entry per page but grow a single entry without bound. Cap the merged list or reset it when the user leaves the list.

Tags and invalidation do not remove entries

Invalidating tags triggers refetches for subscribed entries; it does not delete unused ones. Memory is controlled by subscriptions and lifetimes, not by tags.

Persisting the API slice

Persisting RTK Query state to storage and rehydrating it restores entries without subscriptions, which then expire normally — unless lifetimes are infinite. Usually it is better not to persist the API slice at all.

Frequently Asked Questions

What does keepUnusedDataFor do in RTK Query?

It sets how many seconds a cache entry stays in the store after its last subscriber unsubscribes. The default is 60 seconds. If a component subscribes again within that window, the cached data is reused; otherwise the entry is removed.

Why are my RTK Query cache entries never removed?

Either the entry still has a subscription — usually from a manual dispatch(endpoint.initiate(arg)) whose result was never unsubscribed — or the endpoint’s keepUnusedDataFor is infinite or very long. Release manual subscriptions and set finite lifetimes.

Should I set keepUnusedDataFor to Infinity for instant navigation?

Only for small, bounded reference data. For endpoints with many distinct arguments or large payloads, an infinite lifetime keeps everything the user ever viewed. Moderate lifetimes plus prefetching on hover or route intent give fast navigation without unbounded growth.

How do I clear the RTK Query cache on logout?

Dispatch api.util.resetApiState(). It removes all cached queries and mutations for that API, which releases memory and prevents data from one user being visible to the next.

How do I check subscription counts for an entry?

Redux DevTools shows the api slice, including a subscriptions section keyed by query cache key; each entry lists the active subscription IDs. An entry for a view you closed long ago that still lists subscribers points to a manual initiate() call or a component that is still mounted somewhere, such as a hidden tab.

Does RTK Query cache mutations too?

Mutation results are tracked while a component uses them and are removed when the last subscriber unmounts, unless you pass a fixed cache key to share them. They rarely cause growth, but check mutation state if you use fixed keys widely.