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:

ToolWhat it shows
Chrome DevTools → PerformanceFrame-by-frame CPU + render breakdown
Chrome DevTools → LighthousePage-level Web Vitals score + suggestions
Chrome DevTools → NetworkRequest waterfall, payload sizes, blocking
React DevTools → ProfilerComponent render times + commit reasons
Vercel Speed InsightsReal-user Web Vitals across deployments
Vercel AnalyticsPage-level traffic + Web Vitals
Supabase Studio → Database → Query performanceSlow query log
npm run build outputBundle 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:

  1. Big JavaScript bundle blocking the main thread.
  2. Large images not optimized.
  3. Long server response time (TTFB > 800ms).
  4. Render-blocking external resources (fonts, third-party scripts).
  5. Server-rendered page is too big (lots of HTML coming over the wire).

Fix paths:

  • Use next/image for all images. Vercel auto-optimizes; serves WebP/AVIF.
  • Use next/font for custom fonts. Self-hosts; eliminates external font request.
  • Defer non-critical scripts with next/script strategy=“lazyOnload” or “afterInteractive”.
  • For TTFB: check your Vercel region pin matches your database region (Bible Quest: both syd1).
  • For bundle size: run npm run build and 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:

  1. Importing a large library on the client (moment, lodash full bundle, an unused chart lib).
  2. Importing entire shadcn/ui set in a single component.
  3. 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-fns instead of moment, nuqs instead 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:

  1. Long tasks on the main thread — large JS execution blocking events.
  2. Expensive component re-renders on every keystroke.
  3. Many concurrent React state updates.
  4. Unmemoized expensive computations in render.

Fix paths:

  • Profile with React DevTools — identify components rendering too often or too slowly.
  • Add useMemo / useCallback for 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:

  1. Client-side state initialized from localStorage that doesn’t match what the server rendered.
  2. Time / locale-dependent rendering.
  3. 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 what next-themes does).

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 ANALYZE to 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/health every 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:

  1. Event listeners not removed when components unmount.
  2. Subscriptions not cleaned up (websockets, Supabase Realtime).
  3. Closures retaining large objects.
  4. 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 of next/image
  • next/image configured with unoptimized: true
  • Image is in a domain not allowed in next.config.js images.domains

Fix paths:

  • Use next/image for ALL non-decorative images.
  • For external images: add the domain to images.domains (or use remotePatterns).
  • Pass width and height so 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


Sources