Sitemap
Dmitrii Lobanov

Dmitrii Lobanov

Senior Frontend Engineer writing about React, TypeScript, frontend performance, and scalable UI architecture.

Press enter or click to view image in full size

Optimizing a Large React/Redux App: From Render Chaos to a 3x Faster UI

19 min readMay 8, 2026

--

A frontend performance case study in state ownership, selector stability, render isolation, and realtime update control.

At some point, our React application stopped feeling fast.

Not because one screen was completely broken. Not because the stack was outdated. And not because React suddenly became “slow”.

It was more subtle than that.

Tables that used to feel instant started feeling slightly delayed. Large forms developed input lag. Dashboards still updated in realtime, but the UI no longer felt calm or predictable. Some pages worked correctly from a functional point of view, but the overall experience felt heavier every week.

That is usually how frontend performance problems appear in real products. Not as one obvious bug. More like a slow accumulation of small rendering costs.

After profiling the application, the main issue became clear — React itself was not the bottleneck.

The real problem was that too many parts of the UI were reacting to changes they did not actually care about.

And once I understood that, the optimization work became much more interesting.

The application was a CPA platform with two major interfaces.

The manager UI was used by internal operational teams to manage offers, products, partners, traffic quality, approvals, conversions, invoices, financial documents, and day-to-day business workflows.

The partner UI was used by external affiliates and traffic partners to search and accept offers, manage referral traffic, generate tracking links, monitor performance, work with documents, and configure access for their own partner managers.

Both sides of the platform were highly interactive. Users worked with infinite-scroll tables, dashboards and reporting widgets, filters and derived metrics, complex forms, approval workflows, financial documents, referral systems, notifications, and live updates.

The platform also included onboarding flows, internal chat, support tools, and websocket-based synchronization running continuously in the background.

Which meant users were almost never interacting with one isolated feature.

Someone could be editing a form while dashboard counters updated, new notifications arrived, conversion statistics synchronized, support chats received messages, and reporting widgets refreshed.

All while the user was still trying to interact with the page.

The stack itself was completely standard for a modern React application: React, TypeScript, Redux Toolkit, RTK Query, REST APIs, WebSockets, Vite, shared component libraries, and modern frontend tooling around builds, state management, and developer experience.

The project also had solid engineering practices around testing and observability: unit tests, component tests, end-to-end coverage with Cypress, and Sentry for production monitoring and debugging.

Nothing about the stack looked suspicious.

And that was part of the problem.

On paper, the architecture looked reasonable. But in practice, the frontend was reacting to far more changes than I realized.

Scrolling became less smooth. Typing latency slowly increased. Some interactions blocked the main thread long enough for users to notice. CPU usage during heavier workflows became higher than it should have been. And once realtime updates were layered on top of already expensive rendering paths, the UI began feeling noisy and unpredictable.

At first, it was tempting to blame the usual suspects.

“Maybe the tables are just too large.”

“Maybe React is struggling with this amount of data.”

“Maybe we simply need more memoization.”

But the profiler showed something different. The bottleneck was not one slow component. It was render propagation. Too many updates were spreading too far through the UI.

The Real Bottlenecks

One thing I learned pretty quickly:

Frontend performance work becomes much easier once you stop guessing.

A lot of React performance advice online is very abstract. You constantly hear things like: just memoize it, use virtualization, avoid re-renders.

And those things are not wrong. But when a large application becomes slow, the problem is usually not one missing useMemo. It is almost always a combination of state ownership, rendering boundaries, unstable references, update frequency, and accumulated complexity over time.

In our case, the bottlenecks fell into a few clear categories:

1. State updates were spreading too far.

2. Components were receiving unstable data.

3. Realtime events were invalidating the UI too often.

4. Too much computation was happening during render.

5. The browser was doing unnecessary layout and paint work after React finished rendering.

The app did not have one catastrophic bottleneck. It had many smaller problems that amplified each other. And most of them were invisible until I profiled the application properly.

1. Global State Updates Were Triggering Huge Render Chains

We relied heavily on Redux across the application, which is completely normal for a large React app.

The problem was not Redux itself.

The real issue was that too many parts of the UI were subscribed to state they did not actually need. Over time, some selectors became too broad, some shared state became too global, and rendering boundaries became blurry.

As a result, relatively small updates started affecting much larger parts of the application than intended.

For example, a websocket event updating a single row inside a reporting table could unexpectedly trigger re-renders in dashboard widgets, filters, unrelated sections of the page, and nested components.

Sometimes I would profile one small interaction and discover half the screen re-rendering for no meaningful reason.

That was one of the biggest surprises.

The app was not struggling because individual components were extremely slow. It was struggling because updates kept spreading through the UI tree far beyond where they were supposed to go.

And once that happens repeatedly in a realtime application, rendering costs compound very quickly.

2. Selector Instability Was Quietly Breaking Memoization

Several selectors were returning fresh arrays and objects on every execution. That quietly broke the memoization we thought we already had.

The data itself had not changed, but React still received new references. So parts of the component tree kept re-rendering unnecessarily.

This was especially visible in dashboards and tables where selectors prepared formatted rows, aggregated statistics, computed statuses, transformed API responses, and permission-based view data.

The fix was not to add more React.memo but to make selector outputs stable. Because if selectors constantly produce new references, many component-level optimizations stop helping.

The tricky part is that this issue is easy to miss. The UI still works. Nothing crashes. But the application slowly keeps re-rendering more than necessary.

And in a large realtime app, those “small unnecessary re-renders” add up surprisingly fast.

3. Large Forms Were Re-rendering Too Much

Forms became another expensive part of the application.

Some larger forms had gradually become too interconnected. Typing into one field could trigger re-renders across validation sections, conditional blocks, nested components, derived fields, and other unrelated parts of the form.

At first, the delay was subtle. But as forms became more complex, the typing latency became harder to ignore. This was especially noticeable in approval workflows and configuration screens with many dynamic dependencies and conditional fields.

Some forms included complex validation logic, dynamically shown sections, derived values, async data dependencies, and realtime updates happening in parallel.

The problem was not that rendering one input was expensive. The problem was that each keystroke triggered far more UI work than users would expect.

4. Render Cascades Were Everywhere

This was probably the biggest problem in the entire application.

One re-render triggered another re-render. That re-render triggered effects. Effects triggered state updates. State updates triggered more re-renders.

And suddenly one small interaction spread through a huge part of the component tree.

The React DevTools flamegraphs made this very obvious. You open a dropdown — half the page re-renders. You update one table row — dozens, sometimes hundreds, of components light up in the profiler.

A lot of those renders were technically “working as designed”. But they were not actually necessary.

That distinction became very important.

The app was not slow because React could not render fast enough. It was slow because updates kept propagating through the UI much further than intended.

Over time, rendering boundaries between features became weaker and weaker. Components depended on shared state indirectly. Effects reacted to changes too broadly. Derived data created new references. Parent re-renders cascaded downward into large subtrees.

Individually, none of these issues looked catastrophic. Together, they created a system where relatively small UI interactions caused a surprising amount of rendering work.

5. WebSocket Updates Started Flooding the UI

Realtime updates were a core part of the product, and from a business perspective, they were genuinely useful.

Partners expected conversion stats to update live. Managers expected dashboards to refresh. Notifications, chats, support events, counters, and reporting widgets all depended on fresh data.

The problem was that the frontend started reacting to all of those updates too aggressively.

Some websocket events triggered global state updates immediately. Those updates triggered re-renders. Those re-renders triggered derived calculations, selectors, effects, and browser work across multiple parts of the UI.

At some point, the application felt like it was permanently re-rendering in the background.

One thing became obvious during profiling — some updates were arriving faster than users could even visually perceive them.

We were spending CPU time updating parts of the UI that nobody could realistically notice changing. That became especially expensive on pages with large tables, dashboards, aggregated metrics, live counters, and multiple active subscriptions.

The frontend was technically realtime. But the amount of synchronization work itself had become part of the performance problem.

6. Large Tables Became Expensive in Unexpected Ways

Tables were one of the heaviest parts of the application, which made sense.

A large portion of the product revolved around reporting, conversions, traffic analysis, partner management, and operational data.

We already used incremental loading with infinite scrolling. Initially, the frontend requested the first portion of rows from the backend using offset=0 and limit=N. As users scrolled, the application loaded additional chunks of data using updated offsets and the same limit size.

So the problem was not simply “too many DOM nodes.”

The more interesting issue was how much work each individual row was doing. Most rows were not just plain text. They contained formatted and derived values, conditional rendering, computed statuses, action menus, permission-based controls, realtime updates, nested components, memoization layers, and dynamic styling.

Once you multiply that by hundreds or thousands of rows, even small inefficiencies become expensive.

The bottleneck was often not the DOM rendering itself. A lot of the cost happened before React even rendered anything.

Rows recalculated derived values. Selectors produced unstable references. Formatting logic reran repeatedly. Parent updates propagated through the table tree.

So even though infinite scrolling reduced the amount of visible DOM, the application still spent a significant amount of time processing and re-rendering table data unnecessarily.

And because tables existed almost everywhere in the product, those costs accumulated quickly.

7. Too Much Work Was Happening During Render

Another thing that surprised me was how much computation happened directly inside render cycles.

Not huge computations. Mostly lots of smaller ones: data transformations, formatting, mapping nested structures, derived statuses, permission calculations, and conditional UI preparation.

To be fair, we had already made an important architectural decision early on: all heavy sorting, filtering, and pagination logic was delegated to the backend.

That helped a lot. The frontend was not trying to process massive datasets entirely on the client side. But even with that decision in place, the UI still accumulated a large amount of smaller derived work spread across many components.

And that kind of cost is deceptive. Individually, each calculation looks harmless. But once the application starts re-rendering frequently, those computations repeat over and over across tables, dashboards, widgets, forms, and nested component trees.

Some renders were expensive not because React rendering itself was slow, but because components kept recalculating values they did not need to recompute.

In other words, the application was not only re-rendering too much. It was also recomputing too much during those re-renders.

Investigation Process

Press enter or click to view image in full size

I started with React DevTools Profiler.

Not because it gives magical answers but because at some point you need to stop guessing and actually look at where the application is spending time.

The first profiling sessions were worse than I expected. The flamegraphs were huge. Some interactions triggered massive render trees even when almost nothing visibly changed on the screen.

You click one dropdown — half the page re-renders. You update one table row — dozens or hundreds of components light up in the profiler.

That was the moment when the problem started feeling less like “a few slow components” and more like a systemic rendering issue.

One realization became very clear:

Large React applications rarely become slow because of one catastrophically expensive component. They become slow because too many components re-render too often.

That completely changed the way I approached the investigation. I stopped looking for “the slow component”. Instead, I started tracing render propagation through the application.

The questions became:

  • Why did this re-render?
  • What state update triggered it?
  • Was this re-render actually necessary?
  • Why did memoization fail here?
  • Why is this selector returning a new reference?
  • Why is this component subscribed to this piece of global state?
  • Why did this update spread so far through the tree?

The deeper I profiled the application, the more obvious it became that most of the problems were architectural rather than computational.

The frontend was not struggling because React itself was slow. It was struggling because updates kept propagating through the application far beyond where they actually needed to go.

That shifted the optimization process from “micro-optimizing components” into rethinking rendering boundaries, subscriptions, state ownership, and update propagation across the application.

Understanding the Browser Side

Another thing that helped was stepping outside of React for a moment.

At first, I focused almost entirely on component re-renders, memoization, selectors, and state updates. But React rendering was only part of the story.

The browser still has work to do after React finishes rendering: recalculate layouts, update the render tree, paint pixels, and composite layers on the screen. And in complex applications, that work becomes expensive surprisingly quickly.

This helped explain why some interactions felt heavier than they looked. Sometimes the React render itself was relatively fast, but the browser still had to recalculate large portions of the page afterward.

That was especially noticeable in dashboards with many independently updating widgets, large scrolling pages, complex nested layouts, and sections with constantly changing metrics and counters.

Frequent state updates combined with large dynamic layouts caused unnecessary layout recalculations and paint work across parts of the UI that visually barely changed.

At some point, performance optimization stopped being only about React. It became about reducing how much work the browser itself had to do after every update.

Get Dmitrii Lobanov’s stories in your inbox

Join Medium for free to get updates from this writer.

That understanding later became important when optimizing rendering isolation using CSS containment and layout boundaries.

The Fixes

Press enter or click to view image in full size

There was no single “magic optimization” that suddenly made the application fast again. No one useMemo, no one virtualization library, no one clever React trick.

The improvement came from many smaller architectural changes working together: reducing unnecessary re-renders, limiting update propagation, moving computation out of render paths, lowering browser layout and paint costs, and improving isolation between parts of the UI.

That was probably the most important lesson from the entire process.

Large frontend applications rarely become slow because of one catastrophic mistake. They usually become slow because many small inefficiencies gradually compound over time. Which also means performance improvements tend to work the same way.

Not as one massive rewrite. But as a series of targeted fixes that collectively reduce unnecessary work across the system.

1. Reducing Global Render Propagation

The first major improvement came from reducing how far updates were spreading through the application.

During profiling, it became obvious that too many components were reacting to shared global state changes they did not actually care about.

A relatively small update could propagate through large parts of the UI tree and trigger re-renders across unrelated sections of the application. So a big part of the optimization work focused on tightening rendering boundaries and making subscriptions more intentional.

What I did:

  • split broad selectors into smaller focused subscriptions
  • moved temporary UI state closer to the components that actually owned it
  • reduced unnecessary dependencies on global state
  • stopped passing unstable objects and derived data deep through component trees
  • isolated feature-specific updates more aggressively

None of these changes looked dramatic individually. But together they significantly reduced render propagation across the application.

And this was one of the first moments where the profiler started looking noticeably healthier.Interactions that previously triggered huge re-render chains became more localized and predictable.

Instead of half the page reacting to one update, only the parts that actually depended on that data updated.

2. Stabilizing Selectors

Another major improvement came from stabilizing selectors and derived data.

During profiling, I kept running into situations where memoization technically existed, but was still not helping very much.

On paper, many parts of the UI already had optimization layers. But the profiler still showed unnecessary re-renders everywhere. Eventually the issue became obvious: a lot of selectors were returning new object and array references on every execution, even when the underlying data itself had not changed. Which meant React kept seeing “new” props all the time.

And once that happens, memoization becomes much less effective than people expect. So a large part of the cleanup involved rewriting selectors and derived transformations to return stable references whenever possible.

What I did:

  • reduced unnecessary object recreation
  • memoized expensive derived transformations properly
  • avoided generating new arrays during every render cycle
  • separated stable data from frequently changing state
  • reduced derived calculations inside selectors where possible

This made a surprisingly big difference. A lot of situations that initially looked like “React memoization problems” were actually selector stability problems underneath. And once selectors became predictable, memoization finally started working the way it was supposed to.

The profiler results became much cleaner almost immediately.

3. Isolating Form State

Forms were another area where reducing update scope made a huge difference.

Some larger forms had gradually become too interconnected. Typing into one field could trigger re-renders across validation sections, conditional blocks, nested components, derived fields, and unrelated parts of the form.

And once forms became large enough, users could feel that delay while typing.

So one of the bigger improvements came from restructuring forms so updates stayed much more local. Instead of large sections re-rendering together, only the fields that actually changed updated.

What I did:

  • reduced unnecessary shared form subscriptions
  • isolated field-level updates more aggressively
  • separated derived form logic from render cycles
  • reduced parent-driven re-renders inside complex forms
  • moved transient state closer to individual form controls where possible

This was one of the most immediately noticeable UX improvements in the entire project. I could feel the difference almost instantly. Typing became smoother. Conditional sections reacted faster. Large forms stopped feeling heavy.

And the profiler reflected that improvement as well: one keystroke no longer triggered half the form tree to re-render.

4. Virtualizing and Simplifying Tables

Tables were one of the biggest optimization targets in the application.

A huge portion of the product revolved around reporting, conversions, partner management, operational workflows, and analytics. Users constantly interacted with large datasets throughout the day.

We were already using infinite scrolling and incremental loading, so the frontend was not rendering every row at once.

But profiling showed that rendering cost per row was still much higher than it should have been So I started optimizing table rendering more aggressively.

What I did:

  • virtualization
  • row-level memoization
  • cached derived values
  • lighter row components
  • reduced nested rendering
  • fewer calculations during render cycles
  • more stable props and references passed into rows

At first, I expected virtualization to solve most of the problem.

It only solved part of it. Virtualization reduces how many DOM nodes exist at the same time. It does not automatically reduce how expensive each rendered row is.

And that turned out to be a very important distinction. Many rows still contained derived formatting, conditional rendering, computed statuses, permission logic, nested controls, realtime updates, and dynamically generated actions.

So even if fewer rows were visible, each individual row still carried a fairly high rendering and computation cost.

The biggest performance gains came from simplifying the amount of work happening inside each row component. Once rows became lighter, more stable, and less reactive to unrelated updates, the entire table system became significantly smoother.

Scrolling improved noticeably. Realtime updates became less disruptive. And the profiler stopped showing tables as constant hotspots across the application.

5. Batching and Throttling WebSocket Updates

Another major improvement came from changing how realtime updates flowed through the frontend.

Initially, websocket events were applied almost immediately after arriving from the server, which sounds reasonable in theory.

But in practice, the application was receiving updates constantly: conversion changes, dashboard counters, notifications, chat messages, support events, and reporting updates.

And each event could potentially trigger state updates, selector recalculations, re-renders, effects, and browser work across multiple parts of the UI.

At some point, the frontend started spending a noticeable amount of time simply reacting to updates.

So instead of applying every websocket event immediately, I introduced batching and throttling strategies for different parts of the application.

Certain updates were grouped together. Some UI refreshes became interval-based instead of event-based. Some low-priority updates stopped propagating instantly across the entire interface.

Users barely noticed the difference visually. But the profiler definitely did.

Render frequency dropped significantly. UI interactions became smoother. The application stopped feeling like it was constantly re-rendering in the background.

One important lesson from this part of the optimization work:

Realtime systems should optimize for perceived responsiveness, not maximum update frequency.

Users usually do not care whether a counter updated in 50ms or 200ms.

But they absolutely notice when the entire interface becomes noisy, unstable, or sluggish because the frontend is trying to process every update immediately.

6. Moving Expensive Computations Out of Render

Another important improvement came from reducing how much work happened directly inside render cycles.

During profiling, it became obvious that many components were constantly recalculating values during re-renders even when the underlying data barely changed.

Individually, most of those computations did not look particularly expensive. But once the application started re-rendering frequently, those smaller costs compounded quickly across tables, dashboards, widgets, forms, and nested component trees.

So a large part of the optimization work focused on moving expensive or repetitive computations outside render paths whenever possible.

I moved logic into:

  • memoized selectors
  • cached transformations
  • precomputed API formatting
  • async processing layers

I also reduced repeated formatting work, unnecessary data mapping, permission recalculations, and derived computations happening on every render.

The goal was simple:

Render functions should spend as little time “thinking” as possible.

Once components stopped recalculating the same values repeatedly, render cycles became much lighter and more predictable. And that mattered a lot in a realtime application where updates were happening continuously in the background.

Lighter render functions scale much better under high update frequency, especially when combined with reduced render propagation and more stable state subscriptions.

7. Improving Rendering Isolation with CSS

Press enter or click to view image in full size

One optimization that helped more than I expected had very little to do with React itself. It came from improving rendering isolation at the browser level.

During profiling, I noticed that some UI updates triggered more layout recalculation and paint work than they should have, especially on dashboard pages, large scrolling layouts, independently updating widgets, realtime panels, and analytics-heavy screens.

Even when React rendering was reasonably optimized, the browser still had to recalculate layouts, repaint sections of the page, and composite updated layers afterward.

And in complex interfaces, that browser work becomes expensive surprisingly quickly. So I started using CSS containment and rendering isolation more strategically in parts of the application that updated frequently.

In particular, I introduced properties like:

contain: layout paint;

And in some cases:

isolation: isolate;

The goal was to reduce how far layout recalculations and paint invalidation could spread across the page.

Instead of one update forcing the browser to reconsider large portions of the UI, certain sections became much more isolated from the rest of the layout tree.

This worked especially well for independently updating dashboard widgets, realtime metric panels, scrollable containers, isolated reporting sections, and heavy UI blocks with frequent updates.

This is something I rarely see discussed in React performance conversations.

A lot of frontend optimization advice stops at React re-renders. But in large dynamic applications, the browser rendering pipeline itself can become a bottleneck once too much of the page keeps invalidating layout and paint work repeatedly.

Reducing rendering scope at the browser level ended up complementing the React-level optimizations surprisingly well.

The Result

Across the workflows I profiled most often — data tables, large forms, and live dashboards — the UI became roughly 3x more responsive.

But honestly, the raw number was not even the most important part. What mattered more was how the application started feeling again.

Scrolling became smooth. Forms stopped lagging while typing. Realtime updates felt stable instead of noisy. Dashboards became responsive again. CPU usage dropped noticeably during heavier workflows.

And maybe my favorite part — the React Profiler flamegraphs finally stopped looking terrifying.

Press enter or click to view image in full size

Interactions that previously triggered massive re-render chains became smaller, more localized, and easier to reason about.

But the biggest improvement was not just performance. It was predictability.

The rendering model became understandable again. State updates behaved more intentionally. Rendering boundaries became clearer. Feature isolation improved. Profiling sessions became easier to interpret because updates were no longer spreading unpredictably through half the application.

And that changed day-to-day development just as much as the performance improvements themselves.Adding new features stopped feeling risky. Because the frontend was no longer constantly reacting to changes it did not actually care about.

The gains were not only UX wins. They reduced delivery risk because new features no longer triggered unpredictable render cascades.

Final Thoughts

One thing I realized during this project:

Most React performance problems are actually architecture problems disguised as rendering problems.

React itself is usually not the bottleneck. What actually hurts performance in large applications is uncontrolled subscriptions, unstable data flow, excessive render propagation, unnecessary recomputation, weak rendering boundaries, and poor UI isolation.

And the tricky part is that these problems rarely appear all at once.

Applications usually degrade gradually.

A selector here. An unnecessary subscription there. A few derived calculations added into render cycles. A websocket update that spreads slightly too far through the tree.

Individually, none of those decisions look dangerous.

But over time, complexity compounds. And eventually the application reaches a point where the frontend starts doing far more work than anyone intended.

That is why profiling matters early. Not when the application already feels broken.

Because once render cascades become systemic, performance optimization stops being simple cleanup work.

It becomes frontend architecture work. And that is the part of frontend engineering I enjoy the most. Not chasing micro-optimizations. But understanding how data flows through large systems, how rendering propagates through complex UI trees, and how small architectural decisions quietly shape performance over time.

That is where frontend performance stops being “React optimization” and starts becoming systems engineering.

If your team works on complex, data-heavy frontends, I’d be glad to compare notes.

--

--

Dmitrii Lobanov
Dmitrii Lobanov

Written by Dmitrii Lobanov

Senior Frontend Engineer writing about React, TypeScript, frontend performance, and scalable UI architecture.