~/webline_global $

// Everyday tech, explained simply.

Why Your Redux Store Lags on Fast State Updates

· 8 min read
Why Your Redux Store Lags on Fast State Updates

You’ve built a React app. The team is loading it with data, the UI is snappy, and you’re feeling good about your Redux architecture. Then you add a real-time feed — maybe a live scoreboard, a trading ticker, or a multiplayer game state — and the entire store freezes for half a second every update.

That lag isn't a network problem. It’s not your reducer logic being too slow. The culprit is subtle, baked into how Redux works, and it usually catches developers off guard when they push state updates faster than 30 frames per second.

Let’s walk through why your Redux store stumbles on fast state updates, and how to fix it without rewriting your entire stack.

The Root Cause: Synchronous Dispatch in an Asynchronous World

Redux was designed for predictability. Every action flows through a synchronous pipeline: dispatch an action, the reducer computes the new state, and the store notifies subscribers. This is a feature, not a bug — until you try to push updates faster than the browser’s event loop can process them.

When you dispatch 60 actions per second, each one forces a full traversal of your reducer tree and a new subscription notification. The UI thread can’t keep up. You start dropping frames, and your store becomes a bottleneck.

The core issue is that Redux treats every update as equally important. A keystroke debounce and a WebSocket tick both demand the same serial processing. On fast state updates, this creates a queue of work that the browser can’t drain before the next frame.

Why Throttling Isn’t Enough

Many developers reach for lodash.throttle or requestAnimationFrame around the dispatch call. This works for some scenarios, but it introduces latency. If you’re building a real-time game or a live auction UI, even a 50ms delay feels sluggish.

Throttling also masks the problem. You’re still dispatching the same number of actions — you’re just dropping some of them. That can lead to missing updates, inconsistent state, or a UI that stutters instead of freezing.

The better approach is to reduce the number of dispatches without losing data fidelity. That means batching.

Batching Dispatches: The First Real Fix

React 18 introduced automatic batching, but it only works inside React event handlers and lifecycle methods. If you’re dispatching from a WebSocket callback or a setTimeout, each dispatch triggers a separate render.

You can manually batch dispatches using ReactDOM.unstable_batchedUpdates in React 17 or rely on React 18’s startTransition. But even that only helps with rendering — the reducer still runs synchronously for every action.

The real win comes from batching at the action level. Instead of dispatching 60 individual UPDATE_POSITION actions per second, you aggregate updates into a single action that contains an array of changes.

// Instead of this:
socket.on('position', (data) => dispatch(updatePosition(data)));

// Do this:
let buffer = [];
socket.on('position', (data) => {
  buffer.push(data);
  if (buffer.length >= 10) {
    dispatch(batchUpdatePositions(buffer));
    buffer = [];
  }
});

This cuts your dispatch count by an order of magnitude. The reducer runs once, the store notifies subscribers once, and React reconciles once. Your UI stays fluid.

The Batch Size Trade-Off

Choosing the right batch size is an art. Too small, and you’re back to the same problem. Too large, and you introduce perceptible lag between the event and the UI update.

For most real-time apps, a batch size of 10 to 20 events or a time window of 50ms works well. You can combine both: flush the buffer when it reaches a threshold or when a set interval expires, whichever comes first.

This pattern is common in iGaming platforms where every millisecond of latency affects the player experience. Live dealer games, for example, stream position data at 30Hz. Batching that stream into 50ms chunks keeps the UI smooth without losing any data points.

Subscriptions and Selectors: The Hidden Performance Killer

Even if you batch dispatches, your store might still lag. The problem often shifts from the reducer to the selectors and subscribers.

Every time the store updates, every connected component runs its mapStateToProps or selector function. If you have 200 components each computing derived data from a large state tree, that’s 200 function calls per dispatch. With 60 dispatches per second, that’s 12,000 selector executions per second.

Most of those selectors are doing unnecessary work. They might be filtering arrays, mapping objects, or computing totals — all of which could be memoized or precomputed.

Memoization with Reselect

The reselect library creates memoized selectors that only recompute when their input arguments change. This is your first line of defense against redundant calculations.

import { createSelector } from 'reselect';

const selectPlayers = (state) => state.game.players;
const selectActivePlayers = createSelector(
  [selectPlayers],
  (players) => players.filter(p => p.active)
);

This selector only re-runs when state.game.players changes. If you dispatch an action that modifies a different slice of state, the memoized value is returned instantly.

But even memoized selectors have a cost. The shallow equality check on the input selector runs on every store update. If selectPlayers returns a new array reference every time (because your reducer is returning a new object), the memoization is useless.

Structural Sharing Is Not Free

Redux encourages immutable updates, which means every reducer returns a new state object. Structural sharing — where unchanged nested objects retain their references — helps, but only at the object level.

When you update a nested property, the parent objects get new references. A selector watching the top-level state.game will see a new reference and recompute, even if the data it cares about hasn’t changed.

The fix is to write granular selectors that subscribe to the smallest possible slice of state. Instead of selecting state.game.players, select state.game.players.byId.

The Serialization Tax

Another overlooked performance drain is serialization. If you’re using Redux DevTools, every action is serialized and stored in the extension’s memory. With high-frequency updates, this serialization happens on the main thread.

I once worked on a live sports betting platform where the dev tools alone were causing 200ms freezes during peak trading hours. The team had forgotten to disable the extension in production builds.

Even without DevTools, if you’re using middleware that clones or serializes state (like redux-persist or custom logging middleware), each dispatch pays that cost.

Profiling the Serialization

Open the Performance tab in Chrome DevTools. Record a session where you’re dispatching fast updates. Look for long tasks — anything over 50ms. If you see a pattern of serialization calls between dispatches, that’s your smoking gun.

The fix is straightforward: disable DevTools in production, and audit your middleware for any deep cloning or JSON serialization. Replace JSON.parse(JSON.stringify(state)) with immutable update libraries like Immer (which uses structural sharing) or plain spread operators.

The Middleware Bottleneck

Middleware runs on every dispatch. If you have a chain of five middleware functions, each one executes before the action reaches the reducer. On fast state updates, this chain becomes a serial bottleneck.

Common middleware culprits include:

  • Logging middleware that prints every action to the console
  • Analytics middleware that sends events to a remote server
  • Throttling middleware that introduces artificial delays
  • Validation middleware that checks action payloads

Each of these adds overhead. In a slow-update app, the overhead is negligible. At 60 updates per second, it’s catastrophic.

Conditional Middleware Execution

You can optimize middleware by skipping execution for high-frequency actions. Add a meta.batch flag to your batched actions, and check for it in the middleware:

const loggingMiddleware = (store) => (next) => (action) => {
  if (action.meta?.batch) {
    // Skip logging for batched actions
    return next(action);
  }
  console.log('Action:', action.type);
  return next(action);
};

This keeps your middleware functional for rare actions (like user login) while bypassing it for the high-throughput ones.

When Redux Isn’t the Right Tool

Sometimes the honest answer is that Redux isn’t the right store for high-frequency updates. The architecture was designed for applications where state changes happen at human interaction speed — clicks, form inputs, navigation.

For real-time data streams, consider using a separate state management layer. Many iGaming platforms use a hybrid approach: Redux for UI state and user sessions, and a lightweight reactive store (like Zustand or a custom EventEmitter) for game state.

This separation lets you optimize each store for its workload. The reactive store can use mutable updates and skip middleware entirely, while Redux handles the predictable, low-frequency state that benefits from its strict patterns.

A Concrete Example from Production

I consulted for a startup building a live trivia game. Players answered questions in real time, and the leaderboard updated after every response. The original implementation dispatched an UPDATE_SCORE action for each player, 500 times per round.

The app froze for two seconds at the end of each round. Players saw a blank screen, then the final leaderboard. The experience was terrible.

We moved the leaderboard logic out of Redux entirely. The WebSocket handler maintained a local map of player scores, and emitted a custom event every 100ms with the aggregated data. The React component subscribed to this event directly using useEffect and addEventListener.

The Redux store only received a single ROUND_COMPLETE action with the final scores. The app went from freezing to delivering smooth 60fps updates.

The Long-Term Fix: Rethink Your State Shape

If you’re determined to keep everything in Redux, you need to rethink how your state is structured. Flat, normalized state is essential for fast updates.

Avoid deeply nested objects. Every time you update a nested property, you create new references all the way up the tree. This invalidates selectors and forces re-renders.

Use an entity pattern where each entity type has its own slice, and references are stored by ID. This way, updating a single entity only affects that slice.

{
  players: {
    byId: {
      'abc': { id: 'abc', score: 100 },
      'def': { id: 'def', score: 200 }
    },
    allIds: ['abc', 'def']
  }
}

Updating player abc’s score only creates a new players.byId object and a new players object. The allIds array remains the same reference. Selectors that depend on allIds don’t recompute.

The Takeaway

Fast state updates expose the hidden costs of every Redux pattern you take for granted: synchronous dispatch, selector recomputation, middleware overhead, and serialization. The fix isn’t a single silver bullet — it’s a combination of batching, granular selectors, lean middleware, and sometimes a second store.

Next time you add a real-time feature to your app, profile your dispatch pipeline before you write a single line of UI code. Measure how long each dispatch takes, how many selectors fire, and where the main thread is blocked. That data will tell you exactly where to optimize.

The most performant Redux store is the one that never runs unnecessary work. Start treating every dispatch as a potential bottleneck, and you’ll build apps that stay fast even when the data is flying.