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.
Step-by-Step Fix
- Inspect the cache. In Redux DevTools, open the state tree for your API slice and count entries in
queries, or logObject.keys(store.getState().api.queries).lengthover a session. Verification: you know whether entries accumulate with navigation. - 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 manualinitiate()calls. - Release manual subscriptions. Store the result of
dispatch(endpoint.initiate(arg))and call.unsubscribe()when the consumer is done, or useprefetch/{ subscribe: false }for fire-and-forget loading. Verification: after leaving the view and waitingkeepUnusedDataFor, those entries disappear. - Set lifetimes per endpoint. Remove global
Infinity; give heavy or high-cardinality endpoints shortkeepUnusedDataForvalues (for example 5–15 seconds) and small reference data longer ones. Verification: the cache shrinks after navigation at the expected pace. - Reduce argument cardinality. Debounce search inputs, normalise arguments (sorted filters, trimmed strings), and use
skipfor incomplete input. Verification: entries per search session drop from one per keystroke to one per settled query. - Reset on logout. Dispatch
api.util.resetApiState()when the user logs out. Verification:queriesis empty after logout.
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.
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.
Related
- State Management and Client Cache Memory Leaks — the parent topic
- SWR Cache Memory and Provider Scoping — cache lifetime in SWR
- TTL vs LRU Eviction: Memory Trade-offs — the general theory behind cache lifetimes
- Framework-Specific Memory Optimization — the section overview