Improving Core Web Vitals in React Applications
The obvious optimisations rarely matter. Most performance wins come from removing something that runs continuously.
By Uttam Thapa · · Performance
⚡ Executive Summary (TL;DR)
LCP, CLS and INP have three different causes and therefore three different fixes — which is why treating performance as one number produces work that does not
help. This is the practical version: identify the LCP element before touching it, reserve space for everything that arrives late, keep long tasks off the main
thread, and animate nothing but transform and opacity.
Figure 1: Three metrics, three unrelated causes. Optimising the wrong one is the most common way performance work fails.
Introduction
Performance work is easy to get wrong because the obvious optimisations are rarely the ones that matter. Minifying a file that was already small does nothing. Removing a library that runs on every frame changes everything.
This article covers how I approach Core Web Vitals in React applications, and the specific problems that cause the most damage in practice.
The Three Metrics That Matter
LCP
Largest Contentful Paint
How long until the main content appears. Almost always an image or a heading.
CLS
Cumulative Layout Shift
How much the page moves while loading. Caused by anything that arrives without reserved space.
INP
Interaction to Next Paint
How quickly the page responds to input. A main-thread availability problem, not a rendering one.
| Metric |
Good |
Needs work |
Poor |
| LCP | ≤ 2.5s | 2.5s – 4.0s | > 4.0s |
| CLS | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
| INP | ≤ 200ms | 200ms – 500ms | > 500ms |
These are 75th-percentile thresholds measured on real visits, not lab numbers. A metric that looks fine on your machine and fails in the field is the normal
outcome, not an anomaly — which is why the last section of this article is about measurement rather than optimisation.
Each has a different cause, so each needs a different fix. Treating performance as one number leads to work that does not help.
LCP: Find the Element First
The LCP element is usually a hero image or a large block of text. Before optimising anything, identify which one it is, because the fix differs entirely.
If It Is an Image
- Load it eagerly. Lazy loading the hero image delays the exact thing being measured.
- Set fetchpriority to high so the browser requests it early.
- Serve it at the size it is displayed, not the size it was exported.
- Give it explicit width and height.
If It Is Text
- Reduce render-blocking CSS.
- Avoid a web font that delays the first paint.
- Make sure the content is not waiting on a JavaScript fetch.
A common mistake is applying lazy loading to every image on the page. Below the fold it helps. On the hero image it directly harms the metric.
CLS: Reserve the Space
Layout shift happens when something arrives later and pushes existing content out of the way.
The Usual Causes
- Images without width and height attributes.
- Web fonts swapping and changing text metrics.
- Content injected above existing content after load.
- An empty container that has no reserved height while loading.
The last one is worth dwelling on. In a single-page application with lazy-loaded routes, there is a period where the route component has not arrived yet. If the container holding it has no minimum height, the rest of the page collapses upwards and then jumps back down.
Flex grow does not solve this on its own. Flex distributes surplus space, and if a footer is taller than the viewport there is no surplus to distribute. A minimum height on the content area is what actually reserves the room.
Fonts and CLS
A web font that loads after the first paint causes text to reflow when it swaps in.
The options, in order of how much they cost:
- Use a system font stack. Zero requests, zero shift, renders on the first paint.
- Self-host the font with font-display swap and a size-adjust value matched to the fallback.
- Load from a third party and accept both the request and the shift.
It is worth checking that a declared font is actually being loaded. A font family declared in a config but never imported silently falls back, which means the design was never rendering as intended.
INP: Keep the Main Thread Free
Interaction responsiveness is about what else the main thread is doing when the user clicks.
The Biggest Offenders
- Continuous canvas animation running on every page.
- Event handlers that recompute large amounts of state on mousemove or scroll.
- Animating properties that trigger layout or paint.
- Long synchronous work in a click handler.
Background effects deserve particular scrutiny. A particle animation is easy to add and easy to forget, and it runs forever on every page that includes it. If it also responds to mouse movement, every pointer event triggers recalculation.
Animate Only Transform and Opacity
This is the single most useful rule in front-end animation.
transform, opacity -> compositor only, cheap
width, height, top -> triggers layout, expensive
box-shadow, filter -> triggers paint, expensive
background-position -> triggers paint, expensive
An effect that appears to move something can almost always be expressed as a transform. A blurred shape that drifts across the background should have the blur rasterised once and only the transform animated, so the work stays on the GPU.
Verifying It
It is worth checking the compiled CSS rather than trusting the source, because build tools can add properties to a keyframe. A utility applied through a pseudo-element variant, for example, may end up animating the content property alongside the transform.
Scroll Animations Without the Cost
Entrance animations are usually implemented with a scroll listener per element. At scale that becomes expensive.
A better pattern is one shared IntersectionObserver that toggles a class, with the transition itself defined in CSS. The browser handles the animation, the observer only sets an attribute, and there is no per-frame JavaScript.
IntersectionObserver -> set data-visible
CSS -> transition opacity and transform
Respect Reduced Motion
Every animation should be wrapped so it can be disabled.
@media (prefers-reduced-motion: reduce) {
.reveal { opacity: 1; transform: none; transition: none; }
}
The important detail is that the content must still be visible. Disabling the transition without resetting the initial state hides content permanently from anyone who has opted out of motion.
Measure Before and After
Performance claims are easy to make and easy to get wrong. Useful checks include:
- Bundle size per route, not just total.
- Which chunks load on which pages.
- Whether an effect is running when it is not visible.
- Throttled network and CPU, since loading gaps are invisible on a fast machine.
Key Takeaways
- ✓Identify the LCP element before optimising it. The fix for an image and the fix for text share nothing.
- ✓Never lazy load the hero image. It delays the exact thing being measured.
- ✓Reserve space for anything that arrives late — images, embeds, and lazy routes alike.
- ✓A declared font that is never loaded is a silent design bug, and a layout-shift source on top.
- ✓Animate
transform and opacity only. Everything else invites layout and paint into the frame budget.
- ✓Prefer one shared observer and CSS transitions over per-element scroll listeners.
- ✓Verify against the compiled output, not the source, and throttle the network and CPU while you do it.
Most performance wins come from removing something that runs continuously, rather than from micro-optimising code that runs once.
Frequently asked questions
What is a good LCP, CLS and INP score?
At the 75th percentile of real visits: LCP at or under 2.5 seconds, CLS at or under 0.1, and INP at or under 200 milliseconds. Lab numbers on a fast machine routinely pass while field data fails.
Why did lazy loading images make my LCP worse?
Because you almost certainly lazy loaded the hero image, which is usually the LCP element itself. Lazy loading defers the exact thing being measured. Load it eagerly with a high fetch priority and lazy load everything below the fold.
How do you fix cumulative layout shift in a React app?
Reserve the space before the content arrives: explicit width and height on images, fixed-height skeletons for async content, and placeholders sized to match lazy-loaded routes. CLS is caused by things appearing where nothing was reserved.
Home · Projects · Blog · Services · Résumé · Contact