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.

Entity count across a day of browsing Four browsing bursts through a paginated list. Each burst adds entities to the byId map and the count never falls between bursts, ending the day at forty thousand entities and forty-eight megabytes retained, of which the user can see fifty rows. byId over one working day — the flat stretches are lunch, not eviction 0 20 000 40 000 09:00 → 18:00 morning list browsing report review afternoon search 48 MB retained Rows visible on screen at any moment: 50 — roughly 0.1% of what is retained

Step-by-Step Fix

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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);
});
Store retained size under three policies With no cap the store reaches forty-eight megabytes. Adding a two thousand entity LRU cap holds it at seven. Also removing derived collections from the store brings it to four and a half, with no change to what the user sees. Same day of browsing, three store policies 0 25 MB 50 MB session time → no cap → 48 MB LRU cap 2 000 → 7 MB cap + selectors → 4.5 MB The user experience is identical in all three; only the first one runs out of memory on a phone.

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.

Four rules that keep a store bounded Cap any slice whose size is driven by browsing. Store identifiers rather than derived collections. Reset slices at logout and section boundaries. Never store DOM nodes or rendered markup. Each rule is paired with the growth pattern it prevents. Four rules, four growth patterns removed Cap browsing-driven slices prevents: entity count bounded only by session length Derive in selectors, never in state prevents: a second full copy of the data you already have Reset at logout and section exit prevents: one user's data outliving their session on a shared device Never store DOM nodes or markup prevents: detached subtrees pinned by the one object that never unmounts

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.