Why Your AI-Generated React App Fails Lighthouse: 7 Performance Anti-Patterns and Their Fixes

TL;DR: AI coding tools generate React apps that look fast in development and collapse under real Core Web Vitals scoring. The seven most common reasons: oversized client bundles, unoptimized images, missing memoization on expensive renders, hydration waterfalls, no caching headers, blocking third-party scripts, and naive data-fetching patterns. This post diagnoses each, shows the fix with code, and explains why AI tools tend to skip them.
If you've shipped a React or Next.js app scaffolded by an AI tool — Bolt, Lovable, v0, Cursor, or any of their cousins — there's a good chance you ran Lighthouse, watched the Performance score land somewhere between 38 and 64, and assumed something was wrong with the report.
The report is fine. The patterns AI tools default to are optimized for working code, not for Core Web Vitals. Those are different goals. This guide walks through the seven anti-patterns we see most often in audits of AI-generated apps, and the production fix for each.
Why AI tools generate slow React apps by default
AI coding assistants are trained on a lot of public code. Most public React code is tutorials, demos, and starter repos — none of which are optimized for production performance. The model isn't wrong; it's pattern-matching on the wrong corpus. The result is a working app with reasonable architecture and almost no production hardening.
That production-hardening pass — measuring, profiling, fixing — is where engineers earn their keep. It's also the layer most "prompt to app" workflows skip entirely.
Anti-pattern 1: Importing entire libraries client-side
What it looks like in AI-generated code:
import _ from 'lodash';
import * as Icons from 'lucide-react';
import moment from 'moment';
export default function Dashboard() {
const formatted = moment(date).format('MMM DD, YYYY');
const grouped = _.groupBy(items, 'category');
return <Icons.Calendar />;
}
This pulls the entire lodash (70KB minified), every Lucide icon (500KB+), and moment (~290KB with locales) into the client bundle. For a 50-line dashboard.
The fix — tree-shakeable imports and lighter alternatives:
import groupBy from 'lodash/groupBy';
import { Calendar } from 'lucide-react';
import { format } from 'date-fns';
export default function Dashboard() {
const formatted = format(date, 'MMM dd, yyyy');
const grouped = groupBy(items, 'category');
return <Calendar />;
}
Switching from moment to date-fns alone typically saves 200KB+. The bundlephobia database is the fastest way to spot library bloat before you ship.
Anti-pattern 2: Unoptimized images served as JPEG/PNG
What it looks like:
<img src="/hero.jpg" alt="Hero" />
Lighthouse will flag this whether the image is 400KB or 4MB. AI tools rarely scaffold image optimization because the input image and serving infrastructure aren't part of the prompt context.
The fix — Next.js <Image> with explicit dimensions:
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Descriptive alt text"
width={1200}
height={630}
priority
sizes="(max-width: 768px) 100vw, 1200px"
/>
The Next.js Image component handles AVIF/WebP serving, lazy loading, and responsive sizing automatically. For non-Next stacks, set up an image CDN like Cloudflare Images or imgix and serve <picture> with format negotiation.
The single biggest Largest Contentful Paint (LCP) improvement most AI-generated apps need is on the hero image — and it's almost always a one-component swap.
Anti-pattern 3: Re-rendering everything on every state change
What it looks like:
function ProductList({ products }) {
const [filter, setFilter] = useState('');
const sorted = products
.filter(p => p.name.includes(filter))
.sort((a, b) => a.price - b.price);
return (
<>
<input onChange={e => setFilter(e.target.value)} />
{sorted.map(p => <ProductCard key={p.id} product={p} />)}
</>
);
}
Every keystroke re-runs filter and sort across the entire products array, then re-renders every ProductCard. With 500 products, this is visibly janky.
The fix — useMemo, React.memo, and debounced input:
import { memo, useMemo, useState, useDeferredValue } from 'react';
const ProductCard = memo(function ProductCard({ product }) {
return <div>{product.name}</div>;
});
function ProductList({ products }) {
const [filter, setFilter] = useState('');
const deferredFilter = useDeferredValue(filter);
const sorted = useMemo(
() => products
.filter(p => p.name.includes(deferredFilter))
.sort((a, b) => a.price - b.price),
[products, deferredFilter]
);
return (
<>
<input onChange={e => setFilter(e.target.value)} />
{sorted.map(p => <ProductCard key={p.id} product={p} />)}
</>
);
}
useDeferredValue keeps the input responsive while React schedules the heavy work. React.memo prevents card re-renders when props don't change. The official React docs on useMemo cover when memoization is and isn't worth the complexity — don't memoize everything, but do memoize obvious hot paths.
Anti-pattern 4: Hydration waterfalls in server components
What it looks like in Next.js App Router output:
// app/dashboard/page.tsx
export default async function Dashboard() {
const user = await fetchUser();
const projects = await fetchProjects(user.id);
const activity = await fetchActivity(user.id);
return <DashboardUI user={user} projects={projects} activity={activity} />;
}
Each await blocks the next. fetchProjects doesn't start until fetchUser completes, even though it only needs the user ID. With three sequential 200ms calls, the page is blocked for 600ms before any HTML streams.
The fix — parallel fetches and streaming:
import { Suspense } from 'react';
export default async function Dashboard() {
const userPromise = fetchUser();
return (
<>
<Suspense fallback={<HeaderSkeleton />}>
<UserHeader userPromise={userPromise} />
</Suspense>
<Suspense fallback={<ProjectsSkeleton />}>
<Projects userPromise={userPromise} />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<Activity userPromise={userPromise} />
</Suspense>
</>
);
}
Each section streams independently. The user sees the page shell instantly, individual sections fill in as their data resolves, and the slowest fetch no longer blocks the fastest. The Next.js streaming docs cover the pattern in detail.
Anti-pattern 5: No caching headers, ever
What it looks like:
A next start deployment with no cache configuration, no CDN headers, every asset re-fetched on every navigation. Static assets that should be cached for a year get fetched on every page load.
The fix — explicit cache headers per asset class:
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/_next/static/:path*',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' },
],
},
{
source: '/api/:path*',
headers: [
{ key: 'Cache-Control', value: 'no-store' },
],
},
{
source: '/(.*).(jpg|jpeg|png|webp|avif|svg)',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=2592000' },
],
},
];
},
};
The fingerprinted _next/static bundle gets a year. API routes get nothing. Images get a month. MDN's Cache-Control reference is the canonical guide for the directive set.
Anti-pattern 6: Render-blocking third-party scripts
What it looks like:
// app/layout.tsx
<head>
<script src="https://cdn.example.com/analytics.js" />
<script src="https://widget.example.com/chat.js" />
<script src="https://tags.example.com/pixel.js" />
</head>
Three render-blocking scripts in the head, each adding 200–800ms to First Contentful Paint. AI tools tend to drop scripts into the head because that's where the documentation snippet usually shows them.
The fix — next/script with the right strategy:
import Script from 'next/script';
export default function Layout({ children }) {
return (
<html>
<body>
{children}
<Script src="https://cdn.example.com/analytics.js" strategy="afterInteractive" />
<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />
<Script src="https://tags.example.com/pixel.js" strategy="lazyOnload" />
</body>
</html>
);
}
afterInteractive waits until the page is interactive. lazyOnload waits until the browser is idle. Neither blocks rendering. For non-Next stacks, the equivalent is <script async> for analytics and <script defer> plus an IntersectionObserver trigger for widgets.
Anti-pattern 7: Fetching data in useEffect on every component mount
What it looks like:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`).then(r => r.json()).then(setUser);
}, [userId]);
if (!user) return <Spinner />;
return <Profile user={user} />;
}
Every navigation to /users/123 fires a fresh fetch. No caching, no deduplication, no stale-while-revalidate. Users feel it as a flash of spinner on every navigation.
The fix — a real data layer (TanStack Query or SWR):
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
staleTime: 5 * 60 * 1000,
});
if (isLoading) return <Spinner />;
return <Profile user={user} />;
}
TanStack Query handles caching, deduplication, background refetching, and stale-while-revalidate by default. The same userId mounted twice in the same five-minute window costs you one network request, not two.
Where this leaves AI-generated apps
None of these fixes is exotic. All of them are well-documented, well-supported in the React and Next.js ecosystems, and rarely emitted by an AI scaffold without a follow-up performance pass.
The pattern we see in production audits is consistent: the AI gets you 70% of the way to a working app, and the remaining 30% — performance, security, observability, deploys — is where dedicated engineering time is required. Some teams handle this with an internal performance pass after each AI-assisted feature. Others use platforms like a hybrid AI + human app-building service where the AI scaffold and the production-hardening pass are bundled together. Either approach works; what doesn't work is shipping the raw AI output and assuming Lighthouse will be kind.
Common pitfalls when fixing these
Optimizing without measuring. Run Lighthouse and
next buildanalyze first. Half the "fixes" engineers reach for don't move the metric they think they're fixing.Memoizing everything.
useMemoandReact.memohave non-zero cost. Apply to expensive computations and hot-path components, not every function.Treating Lighthouse as ground truth. Lab data is a starting point; field data from Chrome User Experience Report is what Google actually ranks on.
Fixing one anti-pattern in isolation. Bundle size, hydration, and data-fetching interact. Fix them together and re-measure.
Skipping the build analyzer.
@next/bundle-analyzerwill show you in 30 seconds what's bloating your client bundle. Most AI-generated apps have at least one obvious offender.
Quick checklist: production performance pass for an AI-generated React app
| Step | What to check | Tool |
|---|---|---|
| 1 | Run Lighthouse on production build, mobile profile | Chrome DevTools |
| 2 | Analyze the client bundle for unexpected dependencies | @next/bundle-analyzer |
| 3 | Audit every <img> — replace with optimized component |
Manual |
| 4 | Profile renders on the slowest interactive page | React DevTools Profiler |
| 5 | Convert sequential server-side fetches to parallel + streaming | Code review |
| 6 | Set explicit Cache-Control headers per asset class | next.config.js or CDN |
| 7 | Move third-party scripts to afterInteractive or lazyOnload |
next/script |
| 8 | Add a data-fetching layer with caching and deduplication | TanStack Query or SWR |
| 9 | Re-run Lighthouse, compare, document deltas | Chrome DevTools |
| 10 | Set up RUM for ongoing field monitoring | Vercel Speed Insights, Sentry, or similar |
FAQ
Why does my Lighthouse score change every time I run it?
Lighthouse scoring has natural variance — network conditions, CPU throttling, and rendering work all fluctuate run-to-run. Run Lighthouse five times, throw out the highest and lowest, and average the middle three. For real ranking signal, look at field data from CrUX rather than lab data.
Are AI coding tools getting better at performance?
Slowly. Newer models do reach for next/image and useMemo more often than they did a year ago, but the structural issues — hydration waterfalls, sequential fetches, missing cache headers — are still common because they require understanding of the deployment context, not just the component code.
Should I optimize before launching or after?
Optimize the LCP image, the largest bundle offender, and the data-fetching layer before launching — these affect first impressions and SEO. Everything else can be a post-launch pass driven by real field data. Don't pre-optimize what you haven't measured.
What's a "good enough" Lighthouse score for production?
Performance 80+ on mobile, all Core Web Vitals in the green zone (LCP under 2.5s, INP under 200ms, CLS under 0.1). Going from 80 to 95 is usually worth it for SEO; going from 95 to 100 is rarely worth the engineering time unless you're competing in a search-heavy category.
Do these fixes apply to Vite + React or only Next.js?
Most of them apply directly. The framework-specific pieces — next/image, next/script, App Router streaming — have Vite/React equivalents (react-image, native <script async>, React.lazy + Suspense). The principles are the same; the API surface differs.
Final thought
The performance gap between AI-generated and production-ready React apps isn't a sign that AI coding tools are broken. It's a sign that "generates working code" and "generates fast code" are different problems, and the second one still requires someone — or a tool layer — that knows what to measure and what to fix. Treat the AI scaffold as a starting point, run the seven checks above before you launch, and you'll close most of the gap in a focused afternoon.
The apps that win on performance aren't the ones with the smartest AI tools. They're the ones whose builders didn't trust the first output.




