Why Your React or Next.js App Is Slow — And the 10 Code-Level Fixes That Actually Work
Most React and Next.js apps don't ship slow because of bad design. They ship slow because of skipped optimizations. Here are 10 code-level fixes — explained clearly — that make a measurable difference.

Why Your React or Next.js App Is Slow — And the 10 Code-Level Fixes That Actually Work
Performance problems in React and Next.js rarely come from one big mistake. They come from ten small ones, stacked on top of each other.
Most tutorials cover the "what." This one covers the why behind each fix — because if you understand why something works, you'll actually use it.
No fluff. Let's go.
1. Stop Unnecessary Re-renders with React.memo
Every time a parent component re-renders, all its child components re-render too — even if nothing changed for those children.
React.memo tells React: "only re-render this component if its props actually changed."
const UserCard = React.memo(({ name, email }) => { return <div>{name} — {email}</div>; });
Without it: A single state update at the top level can trigger hundreds of component re-renders down the tree.
With it: Children only re-render when their own data changes.
When to use: On components that render the same output for the same props, especially in lists or dashboards.
When NOT to use: On components where props change almost every render — wrapping them in memo actually adds overhead.
2. Cache Expensive Calculations with useMemo
Some calculations are heavy — filtering a 10,000-item list, building a chart dataset, formatting complex data. Doing that on every render is wasteful.
useMemo runs a function once and remembers the result. It only recalculates when the dependencies you specify actually change.
const filteredUsers = useMemo(() => { return users.filter(u => u.active && u.role === selectedRole); }, [users, selectedRole]);
Without it: The filter runs on every keystroke, scroll, or unrelated state update.
With it: It only runs when users or selectedRole changes.
useMemo is about memoizing a value. Think of it as caching the result of a function.
3. Stabilize Functions with useCallback
When you pass a function as a prop, React creates a new function reference on every render. This breaks React.memo on child components — because the prop technically "changed" (new function reference), even though the logic is identical.
useCallback gives you the same function reference across renders, unless dependencies change.
jsx
const handleSubmit = useCallback(() => { submitForm(formData); }, [formData]);
Rule of thumb: useuseCallbackwhen passing functions to child components that are wrapped inReact.memo, or touseEffectdependency arrays.
4. Split Your Code with Dynamic Imports
Your entire app doesn't need to load upfront. Code splitting means breaking your JavaScript bundle into smaller pieces that load only when needed.
In Next.js, dynamic imports handle this:
jsx
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('../components/HeavyChart'),
{ loading: () => <p>Loading chart...</p>, });
In plain React, use React.lazy:
const Settings = React.lazy(() => import('./Settings'));
Why it matters: A 500KB JavaScript file takes 3–5 seconds to parse on a mid-range Android phone. If that file contains a settings page the user hasn't opened yet — that's 3 seconds wasted on the homepage.
Every route that isn't the homepage is a candidate for code splitting.
5. Use Next.js Image Component Instead of <img>
This one is specific to Next.js and is probably the fastest single-line win you can get.
The standard <img> tag loads the full-size image regardless of the device screen. A 4K banner image loads the same on a phone as on a desktop monitor.
Next.js <Image> component automatically:
- Resizes images based on screen size (responsive by default)
- Converts to WebP or AVIF format (smaller file, same quality)
- Lazy loads images below the fold
- Prevents layout shift (a Core Web Vitals metric)
import Image from 'next/image'; <Image src="/hero-banner.jpg" alt="Relentiv studio banner" width={1200} height={600} priority // Use this only for above-the-fold images />
A 400KB JPEG becomes a 60KB WebP without any visible quality difference. That's a 6x page weight reduction on images alone.
6. Virtualize Long Lists
If you render 1,000 list items in the DOM, the browser draws all 1,000 — even the ones the user can't see.
List virtualization means only rendering what's visible on screen. As the user scrolls, items are swapped in and out of the DOM dynamically.
Libraries: react-window (lightweight) or @tanstack/virtual (more control).
import { FixedSizeList } from 'react-window'; <FixedSizeList height={600} width="100%" itemCount={items.length} itemSize={50} > {({ index, style }) => ( <div style={style}>{items[index].name}</div> )} </FixedSizeList>
Without virtualization: 1,000 DOM nodes = browser is managing 1,000 real elements, all consuming memory and layout calculations.
With virtualization: Only ~15–20 nodes exist in the DOM at any time, regardless of list length.
If your list has more than 100 items — virtualize it. No exceptions.
7. Know When to Use Server Components vs Client Components (Next.js App Router)
This is a Next.js 13+ concept that most people get backwards.
Server Components run on the server. They can directly fetch data from a database, have zero JavaScript sent to the browser, and render to HTML. They cannot use useState, useEffect, or browser APIs.
Client Components run in the browser. They can handle interactivity, state, and effects. They add JavaScript to the bundle.
The default in the App Router is Server Components. You opt into Client Components by adding 'use client' at the top of the file.
// This runs on the server — no JS bundle cost async function BlogList()
{ const posts = await db.getPosts(); // Direct DB call, no API needed return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>; }
'use client'; // This runs in the browser — needed for interactivity
function LikeButton({ postId }) { const [liked, setLiked] = useState(false); return <button onClick={() => setLiked(true)}>Like</button>; }
The optimization: Keep as much of your UI as Server Components as possible. Only push components to the client when they actually need interactivity. This directly reduces your JavaScript bundle size.
A page that uses Server Components for its layout and data, and Client Components only for buttons and forms, can ship 40–60% less JavaScript than an equivalent fully client-side page.
8. Implement Proper Data Caching with SWR or React Query
Making an API call every time a component mounts — even for data that rarely changes — is unnecessary load on your server and causes slow UI updates.
SWR (by Vercel) and TanStack Query (React Query) both handle caching, background refresh, deduplication, and stale-while-revalidate strategies.
// With SWR import useSWR from 'swr';
const fetcher = (url) => fetch(url).then(r => r.json()); function UserProfile({ userId }) { const { data, isLoading } = useSWR(`/api/users/${userId}`, fetcher); if (isLoading) return <Skeleton />; return <div>{data.name}</div>; }
What's happening here:
- First load: fetches and caches the data
- Second visit (same session): shows cached data instantly, then quietly updates it in the background
- Multiple components requesting the same URL: only one network request fires
Stale-while-revalidate means the user sees content immediately, while fresh data loads silently. No loading spinners on repeat visits.
In Next.js App Router, the built-in fetch with cache options can replace this for server-side needs:
jsx
const data = await fetch('/api/posts', { next: { revalidate: 60 } }); // Caches the response, re-fetches every 60 seconds
9. Optimize Your Bundle — Tree Shaking and Import Discipline
Your JavaScript bundle contains every library you import. The bigger the bundle, the longer it takes to download and parse.
Tree shaking is when the build tool removes code that you imported but never actually used. But tree shaking only works when you import specifically.
jsx
❌ Imports the entire lodash library (~72KB) import _ from 'lodash'; const result = _.debounce(fn, 300); // ✅ Imports only the debounce function (~2KB) import debounce from 'lodash/debounce'; const result = debounce(fn, 300);
Same applies to icon libraries, date libraries, and UI component kits.
How to check your bundle: Use Next.js Bundle Analyzer.
bash
npm install @next/bundle-analyzer
Add to next.config.js:
js
const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); module.exports = withBundleAnalyzer({});
Run:
bash
ANALYZE=true npm run build
You'll see exactly what's in your bundle — and what's massive. Identifying one unnecessary 80KB library and swapping it for a 3KB alternative is a real, measurable win.
10. Target Core Web Vitals — LCP, CLS, and INP
Google ranks pages partly based on performance metrics called Core Web Vitals. These are real user experience signals, not arbitrary numbers.
LCP — Largest Contentful Paint How long it takes for the biggest visible element to load (usually a hero image or heading).
Fix: Use priority on your hero image in Next.js <Image>, preload critical fonts, avoid render-blocking scripts.
CLS — Cumulative Layout Shift How much the page layout jumps around while loading. A button that moves 200px because an image loaded late is bad CLS.
Fix: Always set explicit width and height on images and embeds. Reserve space for dynamic content with skeleton loaders.
INP — Interaction to Next Paint How fast the page responds to user clicks, taps, or keyboard input. Replaced FID as of March 2024.
Fix: Break up long JavaScript tasks with setTimeout or scheduler.postTask. Avoid heavy synchronous work in event handlers.
jsx
// ❌ Blocks the main thread — bad INP button.addEventListener('click', () => { processHugeDataset(); // Takes 800ms, freezes the UI }); //
✅ Defers heavy work — good INP button.addEventListener('click', () => { setLoading(true); setTimeout(() => { processHugeDataset(); setLoading(false); }, 0); // Yields to browser, then continues });
Check your scores: PageSpeed Insights — paste your URL and get real-world data from actual Chrome users.
Where to Start if You're Overwhelmed
Not every app needs all 10 optimizations right now. Here's a priority order based on impact:
- Images — Switch to
<Image>in Next.js. Immediate, measurable improvement. (Optimization #5) - Re-renders — Add
React.memoto your list item components. (Optimization #1) - Code splitting — Lazy load any route that isn't the homepage. (Optimization #4)
- Long lists — If you have 100+ items anywhere, virtualize. (Optimization #6)
- Server Components — Audit your Next.js app and move non-interactive components server-side. (Optimization #7)
Do these five first. Profile with React DevTools Profiler. Then tackle the rest based on what the data shows.
The Real Point
Fast apps don't happen by accident. They happen because someone went through this list and did the work.
None of these optimizations are exotic. They're all standard tools that exist in React and Next.js today, documented, stable, and used in production by teams shipping millions of users.
The only difference between a slow React app and a fast one is usually whether someone took the time to apply them.
Built and written by the team at Relentiv — a design and engineering studio in Bengaluru building web and app products for founders who care about performance.