Performance issues
Status: 🟩 COMPLETE Last updated: 2026-06-21 Plain-English tagline: “It feels slow.” The patterns behind sluggish pages, ballooning bundles, hydration thrashing, and the wrong kinds of database queries. Symptoms → cause → fix.
What this is
A reference for performance problems that don’t throw errors but make the app feel bad — slow load, sluggish interaction, high CPU, big bundle, slow database queries. Each entry: symptom → likely cause → fix path.
This is a “common slowdowns” lookup, not a comprehensive performance guide. For deeper rendering-strategy context: SPA vs MPA vs SSR vs SSG 🟩.
How to even tell what’s slow
Before fixing, measure:
| Tool | What it shows |
|---|---|
| Chrome DevTools → Performance | Frame-by-frame CPU + render breakdown |
| Chrome DevTools → Lighthouse | Page-level Web Vitals score + suggestions |
| Chrome DevTools → Network | Request waterfall, payload sizes, blocking |
| React DevTools → Profiler | Component render times + commit reasons |
| Vercel Speed Insights | Real-user Web Vitals across deployments |
| Vercel Analytics | Page-level traffic + Web Vitals |
| Supabase Studio → Database → Query performance | Slow query log |
npm run build output | Bundle sizes per route |
Don’t optimize blind. Find the actual slow thing first.
”Initial page load is slow (high LCP)”
Symptom: Largest Contentful Paint > 2.5s. Pages feel slow to appear.
Common causes:
- Big JavaScript bundle blocking the main thread.
- Large images not optimized.
- Long server response time (TTFB > 800ms).
- Render-blocking external resources (fonts, third-party scripts).
- Server-rendered page is too big (lots of HTML coming over the wire).
Fix paths:
- Use
next/imagefor all images. Vercel auto-optimizes; serves WebP/AVIF. - Use
next/fontfor custom fonts. Self-hosts; eliminates external font request. - Defer non-critical scripts with
next/scriptstrategy=“lazyOnload” or “afterInteractive”. - For TTFB: check your Vercel region pin matches your database region (Bible Quest: both
syd1). - For bundle size: run
npm run buildand check per-route sizes. If a route is > 200 KB, audit imports.
”Bundle size warning: route > 500 KB”
Symptom: npm run build output shows a route exceeding ~500 KB of JS.
Common causes:
- Importing a large library on the client (
moment,lodashfull bundle, an unused chart lib). - Importing entire shadcn/ui set in a single component.
- Forgotten dynamic-import opportunities.
Fix paths:
- Tree-shake your imports.
import { sum } from "lodash";is full lodash;import sum from "lodash/sum";is just sum. - Replace heavy deps with lighter ones —
date-fnsinstead ofmoment,nuqsinstead of full-router URL state. - Dynamic-import for heavy components that aren’t above-the-fold:
const Heavy = dynamic(() => import("./Heavy"), { ssr: false }); - Move logic from Client to Server Components — anything that doesn’t need state in the browser shouldn’t ship JS at all.
”Page feels sluggish to interact with”
Symptom: Clicks lag; typing in inputs has visible delay; INP (Interaction to Next Paint) high.
Common causes:
- Long tasks on the main thread — large JS execution blocking events.
- Expensive component re-renders on every keystroke.
- Many concurrent React state updates.
- Unmemoized expensive computations in render.
Fix paths:
- Profile with React DevTools — identify components rendering too often or too slowly.
- Add
useMemo/useCallbackfor genuinely expensive computations. With React Compiler 19+, this is mostly automatic. - Debounce input handlers:
const debouncedSearch = useMemo(() => debounce(handleSearch, 200), []); - Split large components into smaller ones — React can skip re-rendering the parts that didn’t change.
”Hydration thrashes / flashes wrong content”
Symptom: Page briefly shows the server-rendered version, then snaps to a different one when JS hydrates.
Common causes:
- Client-side state initialized from
localStoragethat doesn’t match what the server rendered. - Time / locale-dependent rendering.
- Feature flags loaded client-side.
Fix paths:
- Render a stable placeholder during SSR; swap to the real value in
useEffect. - Persist preferences server-side (cookies that the server reads).
- For theme switching: use a tiny inline script in
<head>that sets the class BEFORE React hydrates (this is whatnext-themesdoes).
Reference: Browser errors — Hydration failed 🟩
“N+1 queries / slow database access”
Symptom: A page takes seconds to load because it’s making many small queries.
Cause: Common pattern — fetch a list, then for each item make another query for its details.
// BAD: 1 + N queries
const posts = await db.from("posts").select("*");
const enriched = await Promise.all(
posts.map(async p => ({
...p,
author: await db.from("users").select().eq("id", p.author_id).single(),
}))
);
// GOOD: 1 query with join
const posts = await db
.from("posts")
.select("*, author:users(*)");Fix paths:
- Use Supabase’s foreign-key syntax to fetch related data in one query.
- For complex relationships, write a Postgres view or RPC function and query it.
- Cache static-for-the-day data (e.g. yesterday’s leaderboard) in Vercel’s data cache or as ISR.
”Missing index on a frequent query”
Symptom: A specific query takes hundreds of ms or seconds; Supabase Studio’s slow query log shows it.
Cause: Postgres is doing a full table scan because there’s no index covering the WHERE / ORDER BY columns.
Fix paths:
- Identify the slow query in Supabase Studio → Database → Query performance.
- Add an index:
CREATE INDEX posts_author_created_idx ON posts (author_id, created_at DESC); - For composite queries, the index column order matters — match the WHERE clause’s leading columns.
- Run
EXPLAIN ANALYZEto verify the index is being used.
Reference: Indexes and performance đźź©
“Vercel function cold start is slow”
Symptom: First request after idle period takes 1-3 seconds; subsequent requests are fast.
Cause: Serverless functions sleep when idle. Cold start = boot the runtime + load the bundle.
Fix paths:
- Smaller bundle = faster cold start. Remove unused imports.
- Use Edge Functions for latency-critical paths — they have negligible cold starts.
- Keep functions warm with periodic pings (cron job hitting
/api/healthevery few minutes). - Pin to a region matching your database for best subsequent-request perf.
”Page re-renders the entire tree on every navigation”
Symptom: Navigating between pages feels heavy; whole layout re-mounts.
Common causes:
- Layout components inside a page instead of in
layout.tsx - Provider components inside pages instead of at the layout
- State stored in a component that unmounts on navigation
Fix paths:
- Move stable structure to
layout.tsx— Next.js keeps layouts mounted across child page changes. - Use Server Components for layouts when possible — they don’t ship JS at all.
- For state that should persist across pages: lift it to a layout-level provider or persist to URL / cookie.
”Massive memory growth over time”
Symptom: The browser tab gets slower over minutes/hours of use. Memory tab shows ever-growing heap.
Common causes:
- Event listeners not removed when components unmount.
- Subscriptions not cleaned up (websockets, Supabase Realtime).
- Closures retaining large objects.
- Intervals / timeouts never cleared.
Fix paths:
useEffect(() => {
const handler = () => { /* ... */ };
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler); // cleanup
}, []);Every effect that subscribes to something should return a cleanup function.
Reference: Memory, stack, heap 🟩 — leak patterns
”Image isn’t loading the optimized version”
Symptom: Images load slowly; LCP suffers; Network tab shows full-resolution image.
Common causes:
- Using
<img>instead ofnext/image next/imageconfigured withunoptimized: true- Image is in a domain not allowed in
next.config.jsimages.domains
Fix paths:
- Use
next/imagefor ALL non-decorative images. - For external images: add the domain to
images.domains(or useremotePatterns). - Pass
widthandheightso Next.js can serve the right size.
”Tailwind generates huge CSS in production”
Symptom: CSS bundle is hundreds of KB.
Common cause: safelist is too broad, OR content config accidentally pulls in node_modules.
Fix:
// Don't do this:
content: ["./**/*.{ts,tsx}"] // scans node_modules
// Do this:
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"]Audit safelist — only include classes that genuinely can’t be statically discovered.
”AI / LLM call is the slow step”
Symptom: A page or action waits multiple seconds because it’s calling an LLM.
Fix paths:
- Stream the response — show partial output as it generates. The total time is the same but the UX feels much better.
- Cache stable responses — for prompts that don’t change, store the previous response.
- Use a smaller model when possible — Haiku for simple tasks instead of Opus.
- Use prompt caching — repeated prefixes get a 10x cost discount on Claude.
- Batch when not real-time — Anthropic’s Batch API is 50% off for async work.
Reference: Tokens & context windows — prompt caching 🟩, Claude models 🟩 🟦
See also
- Common errors — index 🟩
- Browser errors đźź©
- Build errors đźź©
- Vercel runtime errors 🟩 🟦
- Next.js — common gotchas 🟩 🟦
- React — common gotchas 🟩
- Indexes and performance đźź©
- Async & concurrency đźź©
- Memory, stack, heap đźź©
- time and space complexity đźź©
- CDNs đźź©