Miłosz Orzeł

.net, js, html, arduino, java... no rants or clickbaits.

RTK Slice Reducers Performance

INTRO

Previous two posts (1, 2) were about rendering performance, now it's time for something from the back-of-the-fronted: state management. More specifically: checking the speed at which Redux Toolkit can create slices/reducers and handle dispatched actions. The RTK addressed Redux's primary criticism: amount of boilerplate. Now (read: for many years) a couple of lines of code is enough to get fully functioning store setup with dev tools enabled, reducers with Immer, actions (creators), thunks etc. All with great TypeScript support... Plenty of nice features, but how about the speed

TL;DR: The overhead of building a store and reducers with RTK should not significantly impact application performance in real-life scenarios.

BTW: Check RTK Query for fetching & caching!

 

THE TEST APP

The app was made with Vite Vanilla + TypeScript template (vite: 5.4.10, typescript: 5.6.2) with added Redux Toolkit (@reduxjs/toolkit: 2.3.0).

Live demo is here: https://morzel85.github.io/blog-post-rtk-slice-reducers-performance

The code is here: https://github.com/morzel85/blog-post-rtk-slice-reducers-performance

Performance test app... Click to enlarge...

When "Setup store ..." button is clicked, the application uses createSlice function to make slice reducers with requested amount of actions. Each reducer manages a state that contains numerical properties. The amount of properties matches the amount of actions. Each generated action is designed to control single state property. The count of slices and actions per each slice is controlled by SLICES_COUNT and ACTIONS_PER_SLICE_COUNT constants. On the published version of the app these are set to 500 and 25 respectively, which gives a total of 12.5K actions. Once the slices are defined, the store is created with a call to configureStore and a subscription to store changes is added. When "Dispatch random change" button is pressed, a single action is dispatched to modify single value in one of the state slices...

The 500 * 25 setup is quite big, so let's see a simpler example and assume that we want to have 2 slices with 3 actions in each. In that case, we would have such initial state in store:

{
  slice_0: {
    value_0: 0,
    value_1: 0
  },
  slice_1: {
    value_0: 0,
    value_1: 0
  }
}

Now, let's assume that after clicking "Dispatch random change" button, such action was fired:

{
  type: 'slice_0/changed_0',
  payload: 0.715990726007008
}

After it's handled, the state should look like this:

{
  slice_0: {
    value_0: 0.715990726007008,
    value_1: 0
  },
  slice_1: {
    value_0: 0,
    value_1: 0
  }
}

Here's the code (crammed into main.ts):

import { configureStore, createSlice } from '@reduxjs/toolkit';
import type { EnhancedStore, PayloadAction, ReducersMapObject } from '@reduxjs/toolkit';
import './style.css';

export const SLICES_COUNT = 500;
export const ACTIONS_PER_SLICE_COUNT = 25;

type SliceState = Record<string, number>;
type SliceAction = PayloadAction<number>;
type SliceReducer = Record<string, (state: SliceState, action: SliceAction) => void>;

const setupStore = () => {
  const slices = [];
  let createSliceDurationSum = 0;

  for (let s = 0; s < SLICES_COUNT; s++) {
    const initialState: SliceState = {};
    const reducers: SliceReducer = {};

    // Each slice state should have value_* properties and a matching amount
    // of changed_* actions that modify corresponding value in the state.
    for (let a = 0; a < ACTIONS_PER_SLICE_COUNT; a++) {
      initialState[`value_${a}`] = 0;
      reducers[`changed_${a}`] = (state: SliceState, action: SliceAction) => {
        state[`value_${a}`] = action.payload;
      };
    }

    // Creating slice (RTK API)
    const start = performance.now();
    const slice = createSlice({
      name: `slice_${s}`,
      initialState,
      reducers
    });
    const end = performance.now();
    createSliceDurationSum += end - start;

    slices.push(slice);
  }

  console.log(
    performance.measure('create-slices', {
      start: 0,
      duration: createSliceDurationSum
    })
  );

  // Collecting slice reducers config
  const reducer = slices.reduce((acc, { name, reducer }) => {
    acc[name] = reducer;
    return acc;
  }, {} as ReducersMapObject);

  // Configuring store (RTK API)
  performance.mark('configure-store-start');
  const store = configureStore({
    reducer
  });
  performance.mark('configure-store-end');
  console.log(
    performance.measure('configure-store', {
      start: 'configure-store-start',
      end: 'configure-store-end',
      detail: {
        slices: SLICES_COUNT,
        actionsPerSlice: ACTIONS_PER_SLICE_COUNT,
        actionsTotal: SLICES_COUNT * ACTIONS_PER_SLICE_COUNT,
        statePropertiesTotal: SLICES_COUNT + SLICES_COUNT * ACTIONS_PER_SLICE_COUNT
      }
    })
  );

  // Listening for changes in store (Redux API)
  store.subscribe(() => {
    performance.mark('action-end');
    console.log(performance.measure('action', 'action-start', 'action-end'));
  });

  return store;
};

let store: EnhancedStore | undefined;

// Buttons handling
const setupStoreButton = document.getElementById('setupStore') as HTMLButtonElement;
const randomChangeButton = document.getElementById('randomChange') as HTMLButtonElement;

setupStoreButton.textContent += ` (slices: ${SLICES_COUNT}, actions per slice: ${ACTIONS_PER_SLICE_COUNT})`;

setupStoreButton.addEventListener('click', () => {
  store = setupStore();
  setupStoreButton.disabled = true;
  randomChangeButton.disabled = false;
});

randomChangeButton.addEventListener('click', () => {
  if (store) {
    const sliceName = `slice_${Math.floor(Math.random() * SLICES_COUNT)}`;
    const actionName = `changed_${Math.floor(Math.random() * ACTIONS_PER_SLICE_COUNT)}`;
    const action = {
      type: `${sliceName}/${actionName}`,
      payload: Math.random()
    };

    // Dispatching action (Redux API)
    performance.mark('action-start');
    store.dispatch(action);
  }
});

The code uses Performance API, and logs measurements to the console.

THE RESULTS

The test were run on 7 years old PC with Intel Core i7-7700K and 16 GB RAM with Chrome 130 on Ubuntu 20.04. 

Each test consisted of creating a store and dispatching 5 times (to get average time for action, as these can vary).
I've run each SLICES_COUNT * ACTIONS_PER_SLICE_COUNT test case 3 times and reported the average duration:

  • 100 slices * 10 actions (total actions: 1K, state size: 1.1K properties): 
    create-slices: 15msconfigure-store: 16ms, action: 1ms
  • 50 slices * 50 actions (total actions: 2.5K, state size: 2.55K properties): 
    create-slices: 21msconfigure-store: 17ms, action: 1ms
  • 100 slices * 100 actions (total actions: 10K, state size: 10.1K properties): 
    create-slices: 38msconfigure-store: 18ms, action: 1.3ms
  • 500 slices * 25 actions (total actions: 12.5K, state size: 13K properties): 
    create-slices: 45msconfigure-store: 17ms, action: 2.6ms
  • 500 slices * 500 actions (total actions: 250K, state size: 250.5K properties): 
    create-slices: 320msconfigure-store: 530ms, action: 6ms
  • 1000 slices * 10 actions (total actions: 10K, state size: 11K properties): 
    create-slices: 46msconfigure-store: 17ms, action: 3ms
  • 1000 slices * 100 actions (total actions: 100K, state size: 101K properties): 
    create-slices: 140msconfigure-store: 120ms, action: 4ms
  • 1000 slices * 1000 actions (total actions: 1M - yeah, state size: 1.001M properties): 
    create-slices: 900msconfigure-store: 3320ms, action: 10ms

This is what I got, get the app and test on your machine with various SLICES_COUNT and ACTIONS_PER_SLICE_COUNT values. Just remember to run in release mode because development has safety features enabled and you are likely to see SerializableStateInvariantMiddleware and ImmutableStateInvariantMiddleware warnings for very large stores. For large test cases, and even in release mode, you can expect to see "[Violation] 'click' handler took <number>ms" when the "Setup store ..." button is clicked (the entire setup phase is done synchronously in button event handler to keep things simple).

You got to admit that these test cases start quite big (1K actions), I bet most apps don't need that much. The later test cases are just for fun. If your web app really needs a million unique actions then for sure you got bigger problems than state management :)

Would you like to test some more? Consider adding cases for various store structures (nesting, arrays) and features like slice selectors or extra reducers... GL&HF!

P.S. If tormenting RTK with thousands of actions is not your thing, maybe you will like this (slightly more pragmatic) post: https://en.morzel.net/post/accessing-state-in-redux-thunks