Redux Store Memory Growth and Normalized State
Normalisation is good engineering and, by itself, a permanent memory commitment: every entity written into byId stays there until an action removes it, and most applications never write that action. This page belongs to state management and client cache memory leaks within Framework-Specific Memory Optimization, and covers measuring and bounding the entity maps.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| Heap grows on every list scroll, never falls | Entities added to byId, never removed |
Add an LRU trim reducer to that slice |
| Store retained size is 10× the visible data | Derived collections stored back into state | Replace with memoised selectors |
| Snapshot shows detached DOM under the store | Element refs or rendered markup in state | Store identifiers only |
| Development session grows far faster than production | DevTools extension action history | Re-measure with the extension disabled |
| Memory returns only on reload | No reset on logout or section change | Dispatch a slice reset at those boundaries |
Root Cause: A Reducer Never Decides Something Is Stale
The normalised shape — { byId: {}, allIds: [] } — exists to deduplicate entities and give every consumer one source of truth. It works. What it does not do is expire anything, because a reducer only responds to actions, and “the user is unlikely to look at order 1183 again” is not an event anyone dispatches.
So the growth is arithmetic rather than pathological. A list that fetches 50 rows per page, browsed for 40 pages, writes 2 000 entities. At 1.2 KB retained each — realistic for an order with a few nested fields and some strings — that is 2.4 MB, which is fine. The same session over a week-long shift on a shared terminal reaches 40 000 entities and 48 MB, which on a mid-range phone is a substantial share of the whole tab budget.
Two multipliers make it worse. Derived state — a sorted copy, a filtered view, a joined projection stored back into the store — duplicates the memory of its source and has to be invalidated separately. And anything DOM-adjacent stored in state, such as a measured element or a rendered fragment, pins a detached subtree the same way a detached DOM node held by any other reference would.
Step-by-Step Fix
- Read the store’s retained size. DevTools path: Memory → Heap snapshot → Summary → filter for your root reducer’s state object. Expected: a retained size that surprises people in the room; that surprise is the argument for the rest of the work.
- Attribute it by slice. Command: in the console,
Object.entries(store.getState()).map(([k, v]) => [k, Object.keys(v.byId ?? {}).length]). Expected: one or two slices dominate. - Add a trim reducer. Keep the N most recently touched ids. Verification: dispatch 5 000 entities in a test and assert the key count stops at the cap.
- Delete derived state. Move every sorted or filtered copy into a memoised selector. Verification: the store’s retained size falls by roughly the size of the duplicate.
- Reset at boundaries. Dispatch a slice reset on logout and on leaving a major section. Verification: retained size returns to baseline between sections rather than only on reload.
Command & Code Reference
An LRU trim implemented as an ordinary reducer, so it is testable and visible in the action log.
import { createSlice } from '@reduxjs/toolkit';
const MAX_ENTITIES = 2_000; // ~2.4 MB at 1.2 KB retained per order
const ordersSlice = createSlice({
name: 'orders',
initialState: { byId: {}, allIds: [], touchedAt: {} },
reducers: {
upsertMany(state, action) {
const now = action.meta?.now ?? 0; // injected so reducers stay pure
for (const order of action.payload) {
if (!state.byId[order.id]) state.allIds.push(order.id);
state.byId[order.id] = order;
state.touchedAt[order.id] = now;
}
// Trim least-recently-touched entries once over the cap.
if (state.allIds.length > MAX_ENTITIES) {
const ordered = [...state.allIds].sort(
(a, b) => state.touchedAt[a] - state.touchedAt[b]
);
for (const id of ordered.slice(0, state.allIds.length - MAX_ENTITIES)) {
delete state.byId[id];
delete state.touchedAt[id];
}
state.allIds = state.allIds.filter((id) => state.byId[id] !== undefined);
}
},
resetOrders() {
return { byId: {}, allIds: [], touchedAt: {} };
},
},
});
Derived data as a selector rather than as state. The result lives as long as the component that read it, and no longer.
import { createSelector } from '@reduxjs/toolkit';
// Recomputed only when byId, allIds or the filter changes; nothing is stored.
export const selectVisibleOrders = createSelector(
[(s) => s.orders.byId, (s) => s.orders.allIds, (s) => s.filters.status],
(byId, allIds, status) =>
allIds.map((id) => byId[id]).filter((o) => o.status === status)
);
The regression test. It runs in milliseconds and never flakes, unlike a heap assertion.
import { store } from './store';
import { upsertMany, resetOrders } from './ordersSlice';
test('orders slice stays under its cap', () => {
store.dispatch(resetOrders());
for (let page = 0; page < 200; page++) {
const batch = Array.from({ length: 50 }, (_, i) => ({
id: `o-${page * 50 + i}`, status: 'open', total: i,
}));
store.dispatch({ ...upsertMany(batch), meta: { now: page } });
}
// 10 000 dispatched, 2 000 kept.
expect(Object.keys(store.getState().orders.byId)).toHaveLength(2_000);
});
Verification & Regression Prevention
Verify with a snapshot rather than the store’s own key count: the count proves the cap works, while the snapshot proves nothing else is holding the evicted entities. A common surprise is that a component’s local useState copy, or an in-flight request’s closure, still references entities the store has dropped, so retained size falls by less than expected on the first attempt.
For prevention, make the cap a reviewed constant next to the slice and add the dispatch-loop test above to the suite. Then chart the store’s key counts in your existing telemetry — a slice whose 95th-percentile size climbs release over release is heading for the same problem again with a different entity type.
FAQ
Does Redux ever remove data on its own?
No. A reducer is a pure function from state and action to new state, and nothing in the library decides that an entity is no longer interesting. Every removal is an action you wrote and dispatched, which is why an application with no remove actions has a store that grows monotonically for the life of the tab.
Is the Redux DevTools extension responsible for the growth?
Often, in development. The extension retains an action history with a state snapshot per action, so a long session can hold hundreds of copies of the store. Confirm by measuring with the extension disabled before changing application code — and note that this cost does not exist in production, where the extension is not installed.
Should I put a cap on every entity slice?
Only on slices whose cardinality is driven by user browsing rather than by the domain. A slice holding twelve organisation settings needs no cap. A slice holding every order the user has scrolled past needs one, because its size is bounded only by how long the session lasts.
Related
- State Management and Client Cache Memory Leaks — the shared model for stores and caches
- TanStack Query Cache Garbage Collection Settings — the equivalent policy for a query cache
- Why React Memory Grows on Every Route Change — what else survives a navigation besides the store