Skip to main content

Runbook: Performance Optimization Troubleshooting

Purpose

Troubleshooting guide for diagnosing and resolving performance issues detected during local development verification or CI/CD builds. Addresses common problems related to bundle size, build time, caching, and Core Web Vitals.

When to Use This Guide

Use this runbook when you encounter:

  • CI bundle size check failures (> 10% growth)
  • Local verification warnings from pnpm verify
  • 🐌 Slow build times (> 4.2 seconds local, > 5 seconds CI)
  • 🚫 Missing or incorrect Cache-Control headers
  • 📉 Poor Vercel Speed Insights scores (RES < 90) — Note: Speed Insights requires Vercel Pro plan or paid add-on ($10/month) on Hobby plan
  • 🔴 Core Web Vitals in red zones (LCP > 4s, INP > 500ms, CLS > 0.25)

Speed Insights Paid Feature Note

Status: Speed Insights is a paid add-on on Vercel Hobby plan ($10/month; included in Pro/Enterprise).

For Hobby plan users:

  • @vercel/speed-insights package and <SpeedInsights /> component are already implemented
  • Speed Insights script endpoint will return 204 No Content until feature is enabled
  • To enable: Vercel project → Settings → Speed Insights → Enable (paid add-on)
  • This feature is deferred until post-Phase 4 when budget allows

Verification: Browser DevTools Network tab shows script endpoint status:

  • 204 No Content = Not enabled (current state)
  • 200 OK with content = Enabled and collecting data

Bundle Size Regression

Symptom

CI Failure:

✗ Bundle size check FAILED
Current: 32.5 MB | Baseline: 27.8 MB | Growth: +16.9%
Threshold: 10% | Status: EXCEEDED

Local Verification:

✗ FAIL  Total JavaScript  32.5 MB  27.8 MB  (+16.9%)
⚠ CI build will FAIL - bundle size exceeds 10% growth threshold

Root Causes

  1. New dependencies added without review
  2. Large library imports (e.g., entire Lodash instead of specific functions)
  3. Duplicate dependencies in node_modules
  4. Missing tree-shaking (importing non-tree-shakeable code)
  5. Unoptimized images or assets included in bundle
  6. Development-only code accidentally included in production build

Diagnostic Steps

Step 1: Run Bundle Analyzer

ANALYZE=true pnpm build

What to look for:

  • Browser opens with interactive bundle visualization
  • Check chunk sizes in left panel (largest first)
  • Identify unexpected dependencies or duplicates
  • Look for large vendor chunks (> 500 KB)

Step 2: Compare Package Versions

# View recent dependency changes
git diff HEAD~1 package.json

# Check for new dependencies
git diff HEAD~5 package.json | grep "dependencies"

# List all dependencies with sizes
pnpm list --depth=0

Step 3: Check for Duplicate Dependencies

# Find duplicate packages
pnpm list --depth=Infinity | grep -E "dependencies:" | sort | uniq -d

# Deduplicate (if found)
pnpm dedupe

Step 4: Analyze Import Statements

# Find large imports (e.g., entire libraries)
grep -r "import.*from.*lodash" src/
grep -r "import.*from.*moment" src/

# Should use specific imports instead:
# import debounce from 'lodash/debounce'

Solutions

Solution 1: Remove or Replace Large Dependencies

If dependency is unnecessary:

pnpm remove <package-name>

If dependency can be replaced with lighter alternative:

Heavy PackageLighter AlternativeSavings
momentdate-fns~60 KB
lodash (full)lodash-es (tree-shakeable)~40 KB
axiosfetch (native)~15 KB

Solution 2: Use Specific Imports

Bad:

import _ from 'lodash'; // Imports entire library

Good:

import debounce from 'lodash/debounce'; // Only imports debounce

Solution 3: Implement Code Splitting

For large components not needed on initial page load:

// Before: Direct import
import HeavyComponent from './HeavyComponent';

// After: Dynamic import (lazy loading)
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
loading: () => <LoadingSpinner />,
});

Solution 4: Update Baseline (If Growth is Justified)

If bundle growth is intentional and necessary (e.g., new critical feature):

  1. Document justification in PR description
  2. Update baseline values in BOTH files:
    • docs/performance-baseline.yml:
      bundle:
      total_js_mb: 32.5
      total_js_bytes: 34078720
      size_warning_threshold_mb: 35.8 # 10% above new baseline
      size_failure_threshold_mb: 39.0 # 20% above new baseline
    • docs/performance-baseline.md (update tables with new values)
  3. Update metadata:
    • baseline_date in YAML
    • "Date Recorded" in Markdown
  4. Verify: Run pnpm verify to confirm script reads new values
  5. Get approval from team/reviewer

Verification

After fixes:

# Clean build and verify
rm -rf .next node_modules
pnpm install
pnpm verify

# Check bundle size in output
# Should show: ✓ GOOD Total JavaScript XX.X MB 27.8 MB

Slow Build Time

Symptom

Local verification warning:

⚠ SLOW  Total Build Time  5.2s  3.5s
Build time (5.2s) exceeds warning threshold (4.2s)

Root Causes

  1. TypeScript compilation slowdown (large type inference, circular dependencies)
  2. Excessive static page generation (too many routes)
  3. Slow file I/O (network drives, antivirus scanning)
  4. Missing Turbopack caching (.next/cache cleared or corrupted)
  5. Large registry files (src/data/projects.yml with many entries)

Diagnostic Steps

Step 1: Isolate Compilation vs Generation

# Measure TypeScript compilation only
time pnpm typecheck

# Measure build with minimal routes
# Temporarily reduce projects in src/data/projects.yml to 2 entries
time pnpm build

Step 2: Check Turbopack Cache

# Verify cache directory exists and is being used
ls -lh .next/cache/

# If cache is corrupted, clear and rebuild
rm -rf .next/cache
pnpm build

Step 3: Analyze Build Output

pnpm analyze:build 2>&1 | tee build-log.txt

# Look for slow steps in output:
# - "Compiling" duration
# - "Collecting page data" duration
# - "Generating static pages" duration

Solutions

Solution 1: Optimize TypeScript Configuration

Check for overly broad include patterns in tsconfig.json:

{
"include": [
"src/**/*", // Good: Specific to src
"!src/**/*.test.ts" // Good: Excludes test files
],
"exclude": ["node_modules", ".next", "out", "build"]
}

Solution 2: Reduce Static Page Generation

If many project pages:

// src/app/projects/[slug]/page.tsx

export async function generateStaticParams() {
const projects = await getProjects();

// Before: Generate all pages
// return projects.map(p => ({ slug: p.slug }));

// After: Generate only published/featured pages
return projects
.filter((p) => p.published && p.featured)
.map((p) => ({ slug: p.slug }));
}

Solution 3: Enable Incremental Builds (CI Only)

For CI environments, enable caching:

# .github/workflows/ci.yml
- name: Cache Next.js build
uses: actions/cache@v3
with:
path: |
.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}

Solution 4: Upgrade Hardware/Environment

If local development is consistently slow:

  • Use SSD instead of HDD
  • Exclude project directory from antivirus scanning
  • Increase Node.js memory: NODE_OPTIONS=--max_old_space_size=4096 pnpm build

Verification

# After fixes, build should complete in < 4.2s
time pnpm build

# Expected output:
# ✓ FAST Total Build Time 3.8s 3.5s

Cache Headers Missing

Symptom

Local verification failure:

✗ Missing  Cache-Control Header
Expected: public, max-age=3600, stale-while-revalidate=86400

Root Causes

  1. Missing headers configuration in next.config.ts
  2. Route is dynamically rendered (not static/SSG)
  3. Proxy overriding headers
  4. Development mode (headers only apply in production)

Diagnostic Steps

Step 1: Verify next.config.ts

# Check if headers are configured
grep -A 10 "headers" next.config.ts

Should contain:

async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=3600, stale-while-revalidate=86400',
},
],
},
];
},

Step 2: Check Route Generation Type

# Build and check route symbols
pnpm build | grep "Route (app)"

# Look for:
# ○ Static = headers will apply
# ● SSG = headers will apply
# λ Dynamic = headers may not apply (problem!)

Step 3: Test in Production Mode

# Build and start production server
pnpm build
pnpm start

# In another terminal, check headers
curl -I http://localhost:3000/projects/portfolio-app | grep -i cache-control

Solutions

Solution 1: Add Headers Configuration

If missing from next.config.ts:

// next.config.ts
const config: NextConfig = {
// ... existing config

async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=3600, stale-while-revalidate=86400',
},
],
},
];
},
};

export default config;

Solution 2: Force Static Generation

If route is marked as Dynamic (λ):

// src/app/projects/[slug]/page.tsx

// Add these exports to force static generation
export const dynamic = 'force-static';
export const revalidate = 3600; // ISR: 1 hour

Solution 3: Check Proxy

If headers are being overridden:

// proxy.ts

export function proxy(request: NextRequest) {
const response = NextResponse.next();

// Don't override Cache-Control for static assets
if (!request.nextUrl.pathname.startsWith('/_next')) {
// Add headers only if not already set
if (!response.headers.has('Cache-Control')) {
response.headers.set(
'Cache-Control',
'public, max-age=3600, stale-while-revalidate=86400'
);
}
}

return response;
}

Verification

# Rebuild and test
pnpm build
pnpm start

# Check headers
curl -I http://localhost:3000/projects/portfolio-app

# Should output:
# Cache-Control: public, max-age=3600, stale-while-revalidate=86400

Cache Headers Mismatch

Symptom

Local verification warning:

⚠ Mismatch  Cache-Control Header
Found: public, max-age=7200, stale-while-revalidate=86400
Expected: public, max-age=3600, stale-while-revalidate=86400

Root Causes

  1. Route-specific caching strategy (intentional)
  2. Conflicting headers from proxy or API routes
  3. Outdated baseline (strategy changed but baseline not updated)

Solutions

Solution 1: Verify Intent

If mismatch is intentional (e.g., longer cache for certain routes):

  1. Document in PR description
  2. Update baseline in docs/performance-baseline.md
  3. Update verification script expected value

Solution 2: Unify Caching Strategy

If mismatch is unintentional, standardize across all routes:

// next.config.ts - Single source of truth for cache headers
const CACHE_CONTROL_VALUE =
'public, max-age=3600, stale-while-revalidate=86400';

export default {
async headers() {
return [
{
source: '/:path*',
headers: [{ key: 'Cache-Control', value: CACHE_CONTROL_VALUE }],
},
];
},
};

Poor Speed Insights Scores (RES < 90)

Symptom

Vercel Speed Insights dashboard shows:

  • Real Experience Score (RES) < 90 (orange/red)
  • Core Web Vitals in warning/poor zones

Diagnostic Steps

Step 1: Identify Worst Metric

In Speed Insights dashboard:

  1. Note which metric is red or orange (LCP, INP, CLS, FCP)
  2. Click that metric to see route breakdown
  3. Identify highest-traffic routes with poor scores

Step 2: Drill into Problem Routes

  1. Switch to By Route tab
  2. Sort by Score (lowest first)
  3. Identify routes with:
    • High data points (traffic) AND
    • Low scores (< 50 orange/red)

Step 3: Analyze by Selector

  1. Switch to Selectors tab
  2. See which HTML elements cause issues
  3. Example: img.hero-image causing slow LCP

Solutions by Metric

LCP (Largest Contentful Paint) > 2.5s

Root causes:

  • Large, unoptimized images
  • Slow server response (TTFB)
  • Render-blocking resources

Solutions:

// Use Next.js Image component with optimization
import Image from 'next/image';

<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // Preload above-fold images
quality={85} // Reduce quality slightly
/>

INP (Interaction to Next Paint) > 200ms

Root causes:

  • Heavy JavaScript execution on interaction
  • Blocking event handlers
  • Large client-side state updates

Solutions:

// Debounce expensive operations
import { debounce } from 'lodash';

const handleSearch = debounce((query: string) => {
// Expensive search operation
}, 300);

// Use passive event listeners
element.addEventListener('scroll', handler, { passive: true });

CLS (Cumulative Layout Shift) > 0.1

Root causes:

  • Images without dimensions
  • Ads/embeds loading dynamically
  • Fonts causing layout shift (FOUT/FOIT)

Solutions:

// Always specify image dimensions
<Image
src="/logo.png"
width={200} // Explicit width
height={50} // Explicit height
alt="Logo"
/>

// Reserve space for dynamic content
<div className="ad-slot" style={{ minHeight: '250px' }}>
{/* Ad loads here */}
</div>

Verification

After fixes:

  1. Deploy to Preview environment
  2. Generate traffic (visit affected pages 10+ times)
  3. Wait 10-15 minutes for data
  4. Check Speed Insights dashboard
  5. Verify RES ≥ 90 and metrics in green zones

Troubleshooting Workflows

Workflow 1: CI Bundle Check Failed

Workflow 2: Slow Local Builds

Workflow 3: Missing Cache Headers


Common Error Messages

Error: "Bundle size check FAILED"

Full message:

✗ CI build will FAIL - bundle size exceeds 10% growth threshold
Current: 32.5 MB | Threshold: 30.6 MB
Required actions before pushing:
1. Review bundle composition: ANALYZE=true pnpm build
2. Identify and remove/optimize large dependencies
3. Update docs/performance-baseline.md if growth is justified

Solution: See Bundle Size Regression

Error: "Build time exceeds warning threshold"

Full message:

⚠ Build time (5.2s) exceeds warning threshold (4.2s)

Solution: See Slow Build Time

Error: "Cache-Control header not found"

Full message:

✗ Cache-Control header not found
Expected header: public, max-age=3600, stale-while-revalidate=86400

Solution: See Cache Headers Missing


Prevention Best Practices

Before Committing

# Always run full verification locally
pnpm verify

# If warnings appear, address them before pushing
# Performance regressions are easier to fix early

When Adding Dependencies

# Check package size before installing
npm view <package-name> dist.tarball

# Or use bundlephobia
# https://bundlephobia.com/package/<package-name>

# Install and immediately check impact
pnpm add <package-name>
pnpm verify # Check bundle size change

Regular Monitoring

# Weekly: Run bundle analyzer to track growth
ANALYZE=true pnpm build

# Monthly: Review Speed Insights dashboard
# Look for trends and gradual degradation

Documentation Updates

When performance characteristics change:

  1. Update docs/performance-baseline.md with new baseline
  2. Document reason for change in git commit message
  3. Update CI thresholds in .github/workflows/ci.yml
  4. Notify team in PR description

Escalation

If issues persist after following this guide:

  1. Check Vercel Status: https://www.vercel-status.com/
  2. Review Next.js GitHub Issues: Search for similar problems
  3. Consult with Team: Discuss in PR comments or team chat
  4. File Issue: Create detailed issue with:
    • Steps to reproduce
    • Build output logs
    • Bundle analyzer screenshot
    • Speed Insights dashboard screenshot

Reference

Performance Thresholds

MetricBaselineWarning (+20%)Failure (+20%)
Build Time3.5s4.2s4.2s
Bundle Size27.8 MB30.6 MB (+10%)33.4 MB (+20%)
.next Size337 MB~405 MB~405 MB

Core Web Vitals Targets

MetricGood (Green)Needs Improvement (Orange)Poor (Red)
LCP< 2.5s2.5s - 4.0s> 4.0s
INP< 200ms200ms - 500ms> 500ms
CLS< 0.10.1 - 0.25> 0.25
FCP< 1.8s1.8s - 3.0s> 3.0s
TTFB< 800ms800ms - 1800ms> 1800ms

Useful Commands

# Performance verification
pnpm verify # Full verification with performance checks
pnpm verify --skip-performance # Skip performance checks
pnpm analyze:build # Timed build with output logging
ANALYZE=true pnpm build # Interactive bundle analyzer

# Bundle analysis
pnpm list --depth=0 # List direct dependencies
pnpm dedupe # Remove duplicate dependencies
find .next -name "*.js" | xargs wc -c # Total JavaScript bytes

# Cache testing
pnpm build && pnpm start # Production build + server
curl -I http://localhost:3000/route | grep Cache-Control # Test headers

See Also