← Back
Why React Apps Feel Slow

Why React Apps Feel Slow

Most React performance bugs are really JavaScript, DOM, hydration, or scheduling problems.

·reactperformanceweb-performancerenderingbrowser

I keep coming back to the same conclusion when a React app feels slow: most of the time, React is not the real problem.

Usually it is one of four things:

  • you are doing too much JavaScript work
  • you are asking the browser to manage too much DOM
  • you are forcing expensive browser work after React commits
  • you are treating non-urgent updates as urgent

React is only one part of that pipeline. It does not control the whole thing.

That matters because a lot of React performance work turns into fake work. The app feels slow, so people start wrapping random components in memo, adding useMemo in places they have not measured, and hoping the profiler looks better. Sometimes that helps a little. Usually it just adds complexity while the real bottleneck stays exactly where it was.

The better question is not How do I optimize React? It is Which part of the pipeline is actually expensive, and why?

This article is really about that pipeline, not just React render time. The whole thing:

  • JavaScript execution
  • React render and commit
  • style and layout work
  • paint and compositing
  • hydration
  • scheduling
  • and the gap between "pixels appeared" and "the UI feels responsive"

Once you know where the time is actually going, the fix usually stops feeling mysterious. If you get that part wrong, you can spend days "optimizing React" while the browser is busy with layout, the main thread is blocked by a long task, or hydration is eating the first interaction.

React Runs Inside a Frame Budget

The browser does not care that your code is written in React. It still has to fit everything into a tight frame budget if you want the UI to feel smooth. On a 60Hz display, missing that budget means dropped frames. On faster displays, the margin is even smaller.

That budget gets shared across a lot of work:

  • JavaScript
  • style calculation
  • layout
  • paint
  • compositing
  • input handling

Not every part always runs on the same exact thread in every browser implementation, but that is not the part that usually helps in practice. The more useful model is simpler:

Your JavaScript can monopolize the part of the browser pipeline that gates responsiveness.

Once that happens, the UI feels bad whether React was technically "fast" or not.

{ }JavaScriptMAIN THREADBrowserRENDERING ENGINEGPUCOMPOSITORJavaScript executionsetState, event handlersReact reconciliationVirtual DOM diffingDOM mutationsApplies minimal changesStyle calculationResolves computed stylesLayoutGeometry and positionsPaintRasterizes draw callsCompositingLayers merged to screenPixels on screen

Why One Slow Task Makes the UI Feel Broken

Most frontend performance bugs are not that fancy. They usually come from one boring fact: if you keep the main thread busy for too long, everything else waits.

That includes:

  • the next input event
  • the next animation frame
  • the next paint
  • and often the next meaningful piece of UI feedback

This is why a page can feel broken even when React is doing exactly what it is supposed to do.

If a keystroke triggers:

  • a large synchronous filter
  • a tree-wide rerender
  • a DOM-heavy commit
  • and follow-up layout work

the user does not experience those as separate stages. They just feel input lag.

That is usually the right way to think about UI performance:

not as isolated "React problems," but as different kinds of work fighting over responsiveness.

FRAMEMAIN THREADSHIPPED?123456JSmicropaintJS (long)micropaintJSmicropaintJSmicropaint

React Decides. The Browser Still Has Work To Do.

React does not paint pixels. React calculates UI updates, then commits DOM changes, and then the browser takes over. That handoff is where a lot of the confusion starts.

React has two phases that matter here:

  • render phase: React runs component logic and figures out what changed
  • commit phase: React applies those changes to the real DOM

After commit, the browser may still need to:

  • recalculate styles
  • recompute layout
  • repaint parts of the page
  • composite layers into a final frame

That is why looking only at "render time" is not enough. A render can be cheap and still lead to an expensive frame. A commit can be tiny and still invalidate a large layout subtree. A page can paint quickly and still feel dead because hydration is not finished.

Render PhasePURE, IN MEMORYCommit PhaseTOUCHES REAL DOMHandoffBROWSER PIPELINETriggersetState, props, contextComponent renderFunction components runBuild virtual DOMJS objects in memoryReconciliationDiff new tree vs previousMutate DOMApply the effect listLayout effectsuseLayoutEffect, refsBrowser takes overStyle, layout, paint, compositeBrowser pipeline starts here

Most React Performance Bugs Fall Into Four Buckets

When a React app feels slow, it is usually paying for one of these:

  1. repeated JavaScript work
  2. too much live DOM
  3. forced browser work after commit
  4. bad scheduling

There are other edge cases, but these four explain most of what people end up debugging in real apps.

1. Repeated JavaScript Work

This is the classic render-cascade problem. The app updates one thing, but React ends up re-running a lot more code than that interaction actually needed.

That repeated work can come from:

  • parent rerenders that wake large subtrees
  • unstable object or function props that break bailouts
  • expensive child components rerendering on every keystroke
  • broad context updates that fan out through the tree
  • heavy derived computations running on every render
Live Demo

This demo shows the classic render cascade. The parent updates once, but most of the work below is just noise because only one part of the tree actually needed to change.

Controls

Type or trigger state updates and watch the flash overlay move through every child.

Parent renders
0
Wasted children
0

Render Tree

Three boxes are marked wasted because their output is unchanged, but React still re-runs them.

App
Parent update source
Renders
0
Sidebar
0
Wasted
Header
0
Wasted
Footer
0
Wasted
Content
0
Needed

a single parent update fans out through the entire subtree unless children can bail out

Necessary renders
0
Wasted renders
0
Waste ratio

The important point here is not "rerenders are bad." React rerendering component functions is normal. The problem is rerunning expensive or widespread work for updates that should have stayed local.

Good teams usually fix this earlier in the architecture:

  • move state closer to where it is used
  • stop passing unstable props through large trees
  • isolate expensive children behind stable boundaries
  • split hot state from cold state

Memoization helps, but it is usually a later fix, not the first move.

2. Too Much Live DOM

Sometimes React is not the expensive part at all. The expensive part is keeping a huge amount of UI alive in the DOM.

Large DOM trees increase:

  • memory pressure
  • style recalculation cost
  • layout cost
  • paint cost
  • scroll overhead

That is why giant lists feel bad even when each individual row looks cheap in isolation.

Live Demo

React can finish quickly and the page can still feel slow. Once the browser has to lay out thousands of nodes, even tiny forced reflows start getting expensive.

Controls

Increase the node count, then force the same reflow loop and watch the browser budget disappear.

1001,0005,00010,000
Nodes
100
Reflow loop
50 reads

Dom surface

This is intentionally simple markup. The point is the browser still has to keep track of every node.

same code path, different browser cost once the DOM gets large

Layout time
Verdict
Scale
1x baseline

The browser pays for scale, not just for complexity. If you render thousands of nodes, you are asking the engine to continuously manage thousands of boxes, styles, and paint regions. At some size, that cost dominates everything else.

3. Forced Browser Work After Commit

React can finish its work and you can still lose the frame. Usually that means the browser is doing expensive follow-up work after the DOM changes land.

Common causes:

  • layout thrashing
  • large style invalidation
  • expensive paint regions
  • DOM mutations that trigger broad reflow

Layout thrashing is the easiest example to see. If JavaScript keeps alternating between reading layout and writing DOM changes, the browser loses its chance to batch the work efficiently.

Live Demo

Browsers want to batch layout work. If JavaScript keeps alternating between reading layout and writing DOM changes, the browser gets forced into repeated synchronous reflows.

Controls

Switch between the bad loop and the batched loop, then run the exact same DOM operation.

Boxes
200
Strategy
Thrashing

DOM Surface

The boxes are cheap. The bad read/write order is what turns this into a frame killer.

200 forced layout reads mixed into writes

Loop time
Verdict
Pattern
sync reflow loop

This is one of the places where people misdiagnose the issue all the time.

They see a slow interaction and blame React rerenders. In reality, React may be fine. The expensive part may be the layout work you forced after commit.

4. Bad Scheduling

Some updates are expensive because they are large. Others are expensive because they happen at the wrong priority. Typing into an input is urgent. Recomputing a big result list from that input is often not urgent.

If both happen at the same priority, the slow work competes directly with the feedback the user cares about most.

That is how you end up with interfaces that technically work, but feel heavy.

Scheduling problems became much more visible as React apps turned into editors, dashboards, collaborative tools, and AI-heavy interfaces. Those products are full of updates that matter, but not all of them matter right now.

Stop Guessing: Measure the Right Thing

Once you have those four buckets in your head, the next step is not "optimize." It is "figure out which kind of work is actually hurting you."

Start with the symptom:

  • typing lags
  • scrolling janks
  • first interaction feels dead
  • one route feels heavy after data loads
  • an animation drops frames during unrelated state changes

Then ask which class of work fits that symptom.

If typing lags, likely suspects are:

  • repeated JS work
  • expensive children rerendering
  • large synchronous computations
  • urgent and non-urgent updates competing

If scrolling janks, likely suspects are:

  • too much live DOM
  • expensive layout or paint
  • heavy scroll handlers

If the page looks ready but does not respond, likely suspects are:

  • hydration
  • bundle cost
  • mount-time client work

That step removes a lot of fake optimization work. Before you add useMemo, you should know whether the bottleneck is even repeated JavaScript work. Before you blame React render time, you should know whether the browser is paying the real bill after commit.

React DevTools Profiler and React Scan

This is where tooling becomes useful. The React DevTools Profiler helps you inspect render frequency, render duration, and what rerendered during an interaction.

React Scan is useful for making rerender behavior obvious in real time, especially when you are trying to spot noisy subtrees quickly.

Open the example below to see how React Scan marks rerenders:

Live Demo

The main thing to avoid here is superstition. If you do not know:

  • what rerendered
  • why it rerendered
  • how expensive that rerender was
  • and whether the work was even user-visible

you are not really doing performance work. You are just guessing.

Match the Fix to the Bottleneck

Once you know what kind of work is expensive, the fixes get much more targeted. The mistake is treating every optimization tool like it solves the same problem. It does not.

  • memoization reduces repeated JavaScript work
  • virtualization reduces DOM scale
  • transitions and deferred values improve scheduling
  • SSR and RSC strategies change when and where work happens
  • workerization moves suitable computation off the hot path

If you use the wrong tool for the wrong bottleneck, you usually just buy more complexity without making the UI feel much better.

Render Optimization

Memoization Fixes Repeated Work, Not Everything

Memoization is useful when the problem is repeated JavaScript work. It is not some general performance blessing.

React gives you a few ways to avoid redoing work:

  • memo for component-level bailouts
  • useMemo for expensive derived values
  • useCallback for stable function identity when identity actually matters

The trap is obvious: once people learn these APIs, they start using them everywhere just in case.

That usually means:

  • extra comparison cost
  • extra memory retention
  • harder-to-read code
  • and very little real benefit

The right question is not "can I memoize this?" It is What repeated work am I avoiding, and is that work expensive enough to matter?

Live Demo

A parent rerender does not have to mean redoing every child. useMemo caches expensive derived values and memo lets unchanged rows bail out, so the work only happens when the inputs actually change.

Controls

Toggle memoization off and watch the work counter climb on every parent rerender.

Scores
Cached
Rows
Skip when unchanged

List

Press the parent rerender button. With memoization on, the work counter should stay still.

Parent rerenders: 0
Item 0148
Item 0213
Item 038
Item 0488
Item 0534
Item 0653
Item 0778
Item 0872
Item 0926
Item 1062
Item 1130
Item 128
Item 130
Item 1437
Item 1572
Item 1678
Item 1745
Item 1879
Item 1995
Item 2085
Item 2135
Item 2230
Item 2348
Item 2476
Item 2514
Item 2675
Item 2791
Item 2815
Item 2923
Item 3018
Item 3127
Item 3210
Item 3353
Item 3472
Item 3512
Item 3647
Item 3778
Item 3830
Item 3953
Item 4017
Item 4110
Item 4234
Item 434
Item 4441
Item 4529
Item 4682
Item 4794
Item 4851
Item 4951
Item 503
Item 5136
Item 521
Item 5368
Item 5436
Item 5529
Item 561
Item 5732
Item 5835
Item 5950
Item 6045

scores are cached and rows skip rerendering when nothing about them changed

Parent rerenders
0
Total row work
60

Some blunt rules help here:

  • If the child is cheap, memo usually does not matter.
  • If props are unstable, memo usually does not help.
  • If the real cost is layout, paint, or hydration, memoization is the wrong tool.
  • If moving state down removes the rerender entirely, that is usually better than memoizing half the tree.

Memoization is best when you already found a hot path and can show that the same expensive work keeps repeating with the same inputs.

Virtualization Fixes DOM Scale

Virtualization solves a different problem. It does not make a giant list cheap through magic. It makes the list cheaper by not keeping the whole thing mounted in the first place.

Live Demo

Virtualization is not about caching work, it is about doing less of it. Instead of mounting every row in the list, only the visible slice exists in the DOM at any time. As the user scrolls, rows above and below the viewport are unmounted and replaced.

Controls

Turn virtualization off to mount the whole list at once, then try scrolling.

DOM size
Tiny
Scroll
Smooth

Large list

The list has 2,000 rows. Scroll it with virtualization on and off.

Frame rate
60 fps
Row 00001
#1
Row 00002
#2
Row 00003
#3
Row 00004
#4
Row 00005
#5
Row 00006
#6
Row 00007
#7
Row 00008
#8

only the visible rows exist in the DOM, so scrolling stays smooth on any list size

Total rows
2,000
Rows in DOM
7

That is why virtualization is such a clean optimization. The browser cannot struggle with DOM nodes that do not exist.

This is one of the clearest performance lessons in frontend:

Sometimes the fastest render is the one you never ask the browser to manage.

That said, virtualization is not free either. It introduces tradeoffs:

  • more measurement logic
  • trickier keyboard navigation
  • accessibility work
  • scroll anchoring edge cases
  • harder find-in-page behavior
  • more complexity around dynamic row heights

So the rule is not "always virtualize." If DOM scale is the bottleneck, reduce DOM scale.

Concurrency Is About Interruptibility, Not Speed

When React introduced concurrent rendering capabilities, a lot of people talked about them like they were mostly a speed feature. I do not think that is the right frame. Concurrent React does not make expensive work disappear.

It makes it easier for React to interrupt, reprioritize, and delay work so urgent interactions stay responsive.

That is why APIs like useDeferredValue and startTransition matter.

They are not "optimize later" buttons. They are ways to tell React that some updates can lose the race.

If the user types into an input, the input should win.

If a giant list below the input needs another expensive pass, that update can often wait a beat.

Live Demo

Type into the input and switch sort modes. The input is critical UI. The list recomputation is not. useDeferredValue lets the list trail behind the input, anduseTransition marks sort changes as lower priority work.

Controls

Turn each hook off to feel the expensive list fight the input and sort updates.

Immediate value
Empty
Deferred value
Empty

Expensive list

This list does fake ranking work every time its query or sort changes.

Item 10199
Item 10499
Item 16899
Item 17099
Item 18699
Item 06398
Item 13497
Item 19097
Item 11496
Item 07195
Item 19295
Item 04494
Item 12594
Item 03193
Item 14593
Item 02992
Item 09792
Item 15192
Item 15392
Item 01391

critical updates stay immediate while the slow list is allowed to fall behind

Input
List query
Transition
Enabled

That shift matters because modern React performance is not only about making rendering faster. A lot of it is about protecting urgent interactions from other work that can wait.

Useful rule of thumb:

  • If the user is waiting on direct feedback, the update is probably urgent.
  • If the update can be briefly stale without hurting the interaction, it is a candidate for lower priority scheduling.

Hydration Buys Fast Paint and Sells You CPU Debt

Hydration is where a lot of modern frontend performance claims start falling apart. The page can look ready long before it behaves ready. That is the trap.

Server-rendered HTML improves how quickly content appears.

It does not guarantee that:

  • the JavaScript bundle is loaded
  • client components have executed
  • listeners are attached
  • mount-time work has finished
  • the first real interaction will be responsive

Hydration is the cost of turning server-rendered markup into a live client-side React tree.

That cost usually includes:

  • downloading client JavaScript
  • parsing and executing it
  • building the client-side React tree
  • reconciling it against existing DOM
  • attaching listeners
  • running mount-time client code
Live Demo

The page paints almost immediately, but React still has to parse and execute the client bundle before any of the buttons or inputs work. During that window the UI looks ready and quietly drops everything you do.

Controls

Choose a bundle size, then reload. Try interacting before hydration finishes.

Page
Idle
Bundle
Big (1500ms)

Fake page

Press reload, then immediately try to click the button or type into the input.

Frame rate
60 fps
Press reload to load the page

a big client bundle blocks the main thread for over a second after the page is visible

Time to visible
Time to interactive
Clicks lost
0

This is why so many apps look fast in screenshots and still feel slow when you actually use them. They optimized first paint and quietly moved a large amount of CPU work into the moment right after paint, exactly when the user starts trying to interact.

That is the real cost of hydration. It is not just bytes over the wire. It is main-thread work at the worst possible moment.

This is also why newer frontend architectures keep pushing toward:

  • less client JavaScript
  • narrower client boundaries
  • server components where possible
  • streaming where it helps
  • and avoiding hydration for UI that does not need to wake up immediately

Not all of those techniques solve the same problem, and they should not be treated like synonyms. But they all come from the same realization: shipping less client work is often more valuable than getting HTML on screen slightly earlier.

A Practical Diagnostic Loop

If a React interface feels slow, this is a better order of operations than "add useMemo and pray":

  1. Identify the user-visible symptom.
  2. Capture the interaction in the browser profiler and React profiler.
  3. Classify the bottleneck:
    • repeated JS work
    • too much DOM
    • forced layout or paint
    • bad scheduling
    • hydration
  4. Confirm which stage is actually expensive.
  5. Pick the smallest fix that removes or reprioritizes that work.

That last part matters. The best fix is usually not the cleverest one.

If moving state removes the rerender, do that before memoizing. If virtualization removes 95% of the DOM, do that before shaving a few milliseconds off each row. If the page is hydration-bound, stop tuning component render time and start reducing client-side work. If a transition keeps typing responsive, that may matter more than making the background list absolutely current on every keypress.

The Real Skill Is Knowing What Not to Optimize

A lot of frontend performance advice is still too component-centric. It sounds like this:

  • use memoization
  • split components
  • debounce input
  • lazy load more things

None of that is wrong. It is just incomplete.

The real skill is knowing which work is not worth doing at all. That might mean:

  • not rerendering a subtree
  • not mounting 2,000 rows
  • not hydrating below-the-fold UI right away
  • not running a heavy calculation on every keystroke
  • not treating background updates as urgent

Most good frontend performance work is subtractive. You remove work, narrow work, or delay work. You do not just decorate the same work with more hooks.

Rarely, but worth it

A short note whenever I publish something new.
Plus one newsletter-only post each month.