Home / Articles

When React Memoization Backfires: Why Fewer Renders Made the App Slower

When React memoization backfires: memo, useMemo and useCallback added cost and stale closures to a big page. Moving state and profiling the flow fixed it.

Publisher: REACTAPP.TOP13 min read2888 words

This article is published in English.

Sources: The React Optimization That Made Our App Slower

A case of fixing the wrong thing

Picture a large page that rerenders every time someone types into a filter. It holds tables, charts, summaries and several interactive panels, and the React Profiler lights up with a long list of components. That looks like proof that React is doing far too much work.

The usual cure follows. Components get wrapped in memo, handlers in useCallback, calculations in useMemo, and selectors get custom equality functions. A component that still renders gets more of its props stabilized. An object whose reference changes gets memoized. A comparison that fails gets replaced by a deeper one.

Eventually the profiler does show fewer component renders. The app doesn't feel any faster, though. Some interactions get less responsive, some screens use more memory, and a few components show stale values because a dependency was left out to keep a reference stable. Debugging gets harder too, since performance now depends on caches and comparison rules scattered across the tree. The team had tuned the number that was easy to see and not the work users were actually waiting on.

Using memoization wasn't the error, since React offers these tools to address genuine problems. The flaw was a belief that stopping a render always beats allowing it. Rendering takes effort, and so does deciding whether to skip. When that decision means rebuilding dependency arrays, comparing big props, keeping old values around and running bespoke cache logic, the optimization can end up pricier than the work it was shielding.

What "unnecessary" really means

The first error was in the definition of an unnecessary render. A component showed up in the profiler even though its final DOM output hadn't visibly changed, so the render got labeled as waste. That ignores how cheap many components are.

Take a function component that reads a handful of primitive props and returns a modest element tree. It executes in a blink, React diffs the output and touches almost nothing in the DOM, so blocking that call saves close to zero.

The memoized version has its own job to do. React must keep the previous props, receive the new ones and compare them. With several props, some of them objects, arrays or functions, the comparison may fail often or become expensive enough to cancel any gain. The render count had effectively become a performance score, and lower looked better even when the skipped renders weren't what caused the delay.

It's an easy trap because the profiler makes rendering visible while other costs stay hidden. Prop comparison, JavaScript allocation, cache retention, store selectors, layout, painting and third-party libraries can dominate an interaction while the render count takes all the attention. A rerender is worth preventing when the component does meaningful work and its inputs often stay the same. It isn't worth preventing just because it happened.

What memo actually costs

People often describe memo as a guard that keeps a child from rerendering when props are unchanged. That description leaves out a step: React must first compare the old props to the new ones before it can skip anything.

With primitive props, comparing is cheap. With broad component contracts the cost is harder to predict. Row objects, configuration arrays, permission models, callback collections and nested options were all being passed in. Wrapping the receiver in memo didn't stabilize any of those values. It merely inserted another stage where React checked whether they were stable.

Most of those checks failed, because the parent rebuilt at least one reference while rendering. React compared, then rendered anyway, so effort had been added and nothing removed.

Custom comparison functions followed, meant to inspect values more carefully. One data-grid component checked its columns, which rows were selected, the paging details, whether it was loading, and the rows themselves. That comparator became a second copy of the component's dependency model, and it could fail either way: a thorough one cost real time, while a sloppy one could skip a render that was needed.

A fresh correctness boundary now existed. Anything that influenced rendering had to be mirrored in the comparator, so adding a prop meant editing the component and its memoization logic together. Components that once rendered correctly by default stayed right only while a hand-kept equality function described them flawlessly.

Deep comparison

When shallow comparison fails too often, deep comparison seems like the obvious next step. If two configuration objects hold the same values, why should different references force a render? Compare contents and skip when they're structurally equal.

That works in demos with small objects. In a real application some props hold hundreds or thousands of values, and a deep comparison traverses arrays, inspects nested objects and repeats all of it on each parent update. The protected component may only render a few light rows, or return a third-party component with its own memoization for stable subtrees. The equality check can cost more than calling the component.

Deep comparison also scales with the size of the input and not with the size of the change. A single small state update can send the comparator through a whole collection to prove most of it didn't change.

Structure isn't always a safe basis for comparison either. Functions, dates, maps, class instances and library objects can carry behavior or identity that a generic deep-equality function can't interpret, so even a comparison that looks comprehensive can preserve stale behavior. The team had left React's simple reference model for a custom semantic-equality system without ever defining what equality meant for each domain value.

The stronger fix was rarely to compare unstable objects harder. It was to keep their identity naturally when the underlying data hadn't changed, to narrow the prop contract, or to move the update closer to the component that owned it.

Memoizing values

Values came next. Filtered arrays, configuration objects, formatted labels, lookup maps and tiny calculations were all given useMemo once the components around them were memoized.

A few deserved it. Sorting a big collection or assembling an expensive model can be worth caching if inputs change less often than the parent renders. Many didn't. Concatenating two strings or creating a small object is cheaper than the scaffolding around it: the component still builds a dependency array, React still compares each entry with the earlier one, the previous result still occupies memory and developers still have to judge whether the list is complete.

The code also got visually heavier. A value that used to explain itself in one expression ended up wrapped in a Hook and a dependency array.

const label = useMemo(
  () => `${firstName} ${lastName}`,
  [firstName, lastName]
);

At runtime the cache added next to nothing, and it implied the calculation mattered enough to optimize. Worse, spreading useMemo everywhere clouded the data flow. Developers kept object references stable not because the objects were costly to build but because some other memoized component relied on their identity. Each optimization demanded another, leaving a tree of caches that functioned only while every link held.

Memoization pays off most when it preserves expensive work. Here it was preserving the references that other memoization required, which is a very different goal.

Callbacks and stale closures

Callbacks created during render get a new identity every time. Passed to memoized children, those new references make the child's comparison fail. The response was to wrap nearly every handler in useCallback.

That removed some reference churn but created longer dependency lists. A save handler relied on the draft, the selected record, the current user, permissions, a notification service and the mutation function, and a change to any of them produced a new callback anyway. The callback seldom stayed stable long enough to be reused. The child rendered regardless, and the parent carried one more Hook and one more dependency contract.

Developers sometimes drop dependencies to keep callbacks stable, which creates stale closures. A callback keeps reading an old draft, an outdated permission value or a previously selected record, because preserving identity has become more important than preserving current behavior. Such code can pass tests along the initial interaction path and fail only after state changes without the callback being recreated. These bugs are hard because the function exists and runs without error. It just acts on history. Callback identity had been optimized at the expense of data freshness.

A better question is whether the child needs a stable callback in the first place. For a cheap child that isn't memoized, useCallback achieves little. If the callback feeds a subscription or an expensive memoized boundary, stability counts, and its dependencies can often be trimmed by shifting ownership, passing a narrower operation or placing the behavior nearer the state it reads. A function with a fixed identity isn't faster by nature. It helps only when something meaningful reuses it.

Caches use memory

Caching avoids repeated calculation by keeping earlier results, and that retention costs something. A small memoized value is rarely a memory worry alone. Across a big application, though, many mounted components can hold arrays, maps, transformed records, formatted models and closures that capture further data.

This is part of why a single profiler view can't describe performance. A cached calculation can disappear from the render timeline while it feeds memory growth and longer pauses later. Whether it pays off depends on how big the result is, what rebuilding costs, how frequently it's reused and how long the component stays mounted. A large derived model cached for a rarely opened panel may cost more than rebuilding it when needed. Caches aren't free storage for old work. They swap computation for retained state and more invalidation logic.

The real issue was where state lived

The key finding was that the rerenders reflected where state was placed. A constantly changing filter lived in the page component, which also rendered the charts, summaries, a large table and several panels. Each keystroke rerendered the page and rebuilt props for every child.

Protecting the children with memoization meant stabilizing their arrays, objects and callbacks, plus custom selectors and comparisons, while the architecture still revolved around one broad update boundary. Moving the filter state down next to the table removed most of the problem, and the rest of the page no longer had to defend itself.

Elsewhere the same thing happened. Dialog form state sat in the screen component, hover state lived above an entire card collection, one context carried values with very different update rates, and a global store selector returned more than its consumer used. Memoization was papering over ownership choices that let small changes travel too far.

After those boundaries were tightened, many memoization layers became pointless. Components off the update path stopped rendering since state no longer sat above them, and children got simpler contracts because the parent no longer micromanaged them.

Fewer renders, same browser load

Even where memoization did skip React renders, interactions sometimes stayed slow because React wasn't the main cost.

A charting library kept doing costly imperative updates. A large DOM tree still needed layout once dimensions changed. One CSS effect forced wide repaints, a synchronous storage write blocked the main thread, and an input handler parsed a big document before the next character could appear. Components had drawn the attention only because they were what the profiler measured, and as rendering got cheaper the browser work behind it became visible.

React performance is one slice of frontend performance, not a stand-in for it. Even with memoization in place, a changed Effect dependency can still make a third-party widget rebuild itself. Skipping a parent render doesn't shrink an oversized image's decode time. A stable callback won't cure layout thrash from reading and writing element measurements in the wrong order. The work has to be traced through JavaScript, React rendering, DOM mutation, style calculation, layout, paint and the network, since speeding up one layer might leave the whole path unchanged. React had gone quiet while the browser stayed busy.

Harder-to-change code

The performance cost was only half the failure. The application also got harder to maintain.

Every component boundary raised fresh questions. Is the component memoized? Which props need stable references? Does a custom comparator cover the new prop? Was a value left out of a dependency list on purpose? Will changing one object's shape invalidate several caches? Routine feature work required understanding optimization contracts that had nothing to do with product behavior.

Imagine a developer adding a field to a configuration object and accidentally triggering broad rerenders. A colleague "fixes" those with a deeper comparison. Later someone adds a function to the object and finds the comparator treating two clearly different configurations as equal. The code was tuned to its present shape, and any edit upset a delicately balanced web of references and dependency arrays.

Good performance architecture makes common changes cheaper and doesn't force every change to respect hidden cache assumptions. That doesn't mean optimized code must stay simple. Some high-cost interactions do justify specialized memoization and careful reference handling. The complexity should be proportional to the measured payoff and concentrated around known hotspots, whereas here it had been spread everywhere on the theory that any render might become a hotspot someday.

Measuring the interaction changed the decision

The turning point was profiling complete user interactions in place of counting renders.

Actions that users called slow were recorded and inspected for where the time went. Several components rendered often yet used almost none. A few calculations and third-party widgets dominated, and custom comparisons registered as real JavaScript work. In some places, removing memoization caused slightly more renders but less total time.

That felt wrong at first, because the working mental model treated fewer rerenders as progress. The browser doesn't care how many times component functions are called. It cares how much work happens before the next responsive frame.

From then on, changes were judged by total interaction cost. Did the input respond sooner? Was the main thread left free? Did memory hold steady across long sessions? Did production builds improve, and not just development? Fixes also became local: one costly transformation, one big subtree or one unstable library configuration could be handled directly while the rest of the app dropped its defensive memoization. Profiling didn't deliver a redesign. It just kept the team from chasing a visible number that wasn't behind the user's experience.

What stayed

Not every memo, useMemo and useCallback was removed, since that would repeat the mistake in reverse.

Memoization stayed on expensive components whose props mostly held still. Transformations proven costly and reused across frequent parent renders stayed cached, and callbacks stayed stable where identity mattered to subscriptions, third-party integrations or meaningful memoized boundaries.

What went away: caches around trivial calculations, custom comparison functions doing broad structural checks, and the reluctance to let cheap components render. Frequently changing state moved closer to the interfaces that used it, and context values with little in common were split apart. The outcome wasn't the lowest possible render count. It was an update model that's easier to understand, with optimization applied where evidence justified it. That made the app both faster and easier to change.

Rendering isn't the enemy

This story began with a reasonable instinct: see work happening and try to stop it. The error was treating every render as waste and every skipped render as a win.

Memoization compares inputs, holds values, tracks dependencies and has to stay correct as code changes. Custom equality can cost more than a render. Stable callbacks can freeze stale state. Cached results take memory. And a tangle of optimizations can make a component tree hard to change without any gain users would notice.

Rendering in React is often cheap. Weak state ownership, repeated costly calculations, broad subscriptions, third-party updates and browser layout frequently matter far more. The most effective move was letting cheap work stay cheap: stop wrapping ordinary components in caches and concentrate on the few truly expensive operations. Optimization should cut the total cost of an interaction, not merely the number of times React calls a function. Speed arrived once the question shifted from how to stop React rendering to why each update had grown expensive at all.