Performance

From 3 Seconds to 300ms: A Performance Optimization Journey

Complete guide to optimizing Next.js applications with real metrics and before/after comparisons. Bundle analysis, image optimization, and Core Web Vitals improvements.

Next.js 15 min read
Performance Wins
  • Reduced initial page load from 3.2s to 310ms (90% improvement)
  • Bundle size decreased from 847KB to 192KB
  • Lighthouse score improved from 62 to 98
  • First Contentful Paint improved from 2.1s to 0.6s
  • Time to Interactive dropped from 4.8s to 1.1s

The Performance Problem

Our e-commerce dashboard was slow. Not "a bit sluggish" slow—painfully, user-complaining, customers-leaving slow. The homepage took over 3 seconds to become interactive. Product pages were even worse at 4+ seconds.

We'd launched fast, prioritizing features over optimization. That worked initially, but as our codebase grew and user base expanded, performance degraded. Users noticed. Our analytics showed a direct correlation between load times and bounce rates.

This article documents the systematic approach we took to diagnose and fix our performance issues. Every metric and technique here is battle-tested in production.

Phase 1: Measurement and Baseline

You can't improve what you don't measure. The first step was establishing accurate baselines.

Setting Up Performance Monitoring

// lib/performance.ts
export function reportWebVitals(metric: NextWebVitalsMetric) {
  const body = JSON.stringify({
    name: metric.name,
    value: Math.round(metric.value),
    rating: metric.rating,
    label: metric.label,
    id: metric.id,
    page: window.location.pathname,
  });

  // Send to analytics endpoint
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/analytics', body);
  } else {
    fetch('/api/analytics', { method: 'POST', body, keepalive: true });
  }
}

// pages/_app.tsx
export { reportWebVitals };

Initial Metrics (The Bad News)

Baseline Performance - Homepage
  • First Contentful Paint (FCP): 2.1s
  • Largest Contentful Paint (LCP): 3.2s
  • Time to Interactive (TTI): 4.8s
  • Total Blocking Time (TBT): 890ms
  • Cumulative Layout Shift (CLS): 0.15
  • Total Bundle Size: 847KB (gzipped)
  • Lighthouse Score: 62/100

Phase 2: Bundle Optimization

Our first discovery: we were shipping massive amounts of JavaScript. The bundle analyzer revealed the culprits.

Bundle Analysis

// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.optimization.splitChunks = {
        chunks: 'all',
        cacheGroups: {
          default: false,
          vendors: false,
          framework: {
            name: 'framework',
            chunks: 'all',
            test: /(? 160000;
            },
            name(module) {
              const hash = crypto.createHash('sha1');
              hash.update(module.identifier());
              return hash.digest('hex').substring(0, 8);
            },
            priority: 30,
            minChunks: 1,
            reuseExistingChunk: true,
          },
          commons: {
            name: 'commons',
            minChunks: 2,
            priority: 20,
          },
        },
      };
    }
    return config;
  },
});

Problem 1: Moment.js (230KB)

We were using Moment.js for date formatting. It was pulling in every locale by default.

Solution: Replace with date-fns

// Before: 230KB
import moment from 'moment';
const formatted = moment(date).format('MMM DD, YYYY');

// After: 12KB
import { format } from 'date-fns';
const formatted = format(date, 'MMM dd, yyyy');

Impact: -218KB bundle size

Problem 2: Lodash Imports (95KB)

We were importing the entire lodash library for a few utility functions.

// Before: imports entire lodash
import _ from 'lodash';
const unique = _.uniq(array);

// After: import only what you need
import uniq from 'lodash/uniq';
const unique = uniq(array);

// Even better: use native methods
const unique = [...new Set(array)];

Impact: -87KB bundle size

Problem 3: Icon Libraries (180KB)

We were importing all of react-icons for a handful of icons.

// Before: imports entire icon pack
import { FaUser, FaHome, FaSettings } from 'react-icons/fa';

// After: individual icon imports
import FaUser from 'react-icons/fa/FaUser';
import FaHome from 'react-icons/fa/FaHome';
import FaSettings from 'react-icons/fa/FaSettings';

// Even better: create an icon component that lazy loads
const Icon = dynamic(() => import(`@/components/icons/${iconName}`), {
  loading: () => 
, });

Impact: -165KB bundle size

Bundle Optimization Results

After Bundle Optimization
Total Bundle Size: 377KB (-470KB, 55% reduction)
First Contentful Paint: 1.4s (-0.7s)
Time to Interactive: 2.9s (-1.9s)

Phase 3: Image Optimization

Images accounted for 2.3MB of page weight. Next.js Image component helped, but we needed more aggressive optimization.

Implementing next/image

// Before: standard img tag
<img
  src="/products/hero.jpg"
  alt="Product"
  style={{ width: '100%' }}
/>

// After: Next.js Image with optimization
import Image from 'next/image';

<Image
  src="/products/hero.jpg"
  alt="Product"
  width={1200}
  height={800}
  priority // for above-the-fold images
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,..."
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

Custom Image Loader with CDN

// next.config.js
module.exports = {
  images: {
    loader: 'custom',
    loaderFile: './lib/image-loader.ts',
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
};

// lib/image-loader.ts
export default function cloudinaryLoader({ src, width, quality }) {
  const params = [
    'f_auto', // automatic format selection
    'c_limit', // don't upscale
    `w_${width}`,
    `q_${quality || 'auto'}`,
  ];
  return `https://res.cloudinary.com/demo/image/upload/${params.join(',')}${src}`;
}

Lazy Loading Below-the-Fold Images

// components/LazyImage.tsx
import { useInView } from 'react-intersection-observer';
import Image from 'next/image';

export function LazyImage({ src, alt, ...props }) {
  const { ref, inView } = useInView({
    triggerOnce: true,
    threshold: 0.1,
    rootMargin: '200px', // start loading 200px before visible
  });

  return (
    <div ref={ref}>
      {inView ? (
        <Image src={src} alt={alt} {...props} />
      ) : (
        <div className="image-placeholder" />
      )}
    </div>
  );
}

Image Optimization Results

After Image Optimization
Total Image Weight: 420KB (-1.88MB, 82% reduction)
Largest Contentful Paint: 1.6s (-1.6s)
Cumulative Layout Shift: 0.02 (-0.13)

Phase 4: Code Splitting and Dynamic Imports

Not all code needs to load immediately. We aggressively code-split features and routes.

Dynamic Component Loading

// Before: all components load immediately
import Modal from '@/components/Modal';
import Chart from '@/components/Chart';
import Editor from '@/components/Editor';

// After: load only when needed
const Modal = dynamic(() => import('@/components/Modal'));
const Chart = dynamic(() => import('@/components/Chart'), {
  loading: () => <ChartSkeleton />,
});
const Editor = dynamic(() => import('@/components/Editor'), {
  ssr: false, // client-side only
});

Route-Based Code Splitting

// pages/dashboard/index.tsx
export default function Dashboard() {
  return (
    <Layout>
      <Suspense fallback={<DashboardSkeleton />}>
        <DashboardContent />
      </Suspense>
    </Layout>
  );
}

// Automatically code-split by Next.js
const DashboardContent = lazy(() => import('@/features/dashboard/DashboardContent'));

Conditional Feature Loading

// Only load analytics for admins
function DashboardPage({ user }) {
  const [Analytics, setAnalytics] = useState(null);

  useEffect(() => {
    if (user.role === 'admin') {
      import('@/features/analytics').then((mod) => {
        setAnalytics(() => mod.Analytics);
      });
    }
  }, [user.role]);

  return (
    <div>
      <DashboardHeader />
      {Analytics && <Analytics />}
    </div>
  );
}

Phase 5: Server-Side Optimization

Static Generation vs Server-Side Rendering

// Before: everything was SSR
export async function getServerSideProps() {
  const data = await fetchData();
  return { props: { data } };
}

// After: static generation with ISR
export async function getStaticProps() {
  const data = await fetchData();
  return {
    props: { data },
    revalidate: 60, // regenerate every 60 seconds
  };
}

API Route Optimization

// pages/api/products/[id].ts
import { NextRequest, NextResponse } from 'next/server';

export const config = {
  runtime: 'edge', // use Edge Runtime for faster responses
};

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const id = searchParams.get('id');

  // Add caching headers
  return NextResponse.json(data, {
    headers: {
      'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=30',
    },
  });
}

Database Query Optimization

// Before: N+1 query problem
const users = await db.user.findMany();
for (const user of users) {
  user.posts = await db.post.findMany({ where: { userId: user.id } });
}

// After: single query with joins
const users = await db.user.findMany({
  include: {
    posts: true,
  },
});

Phase 6: Font and CSS Optimization

Font Loading Strategy

// next.config.js
const { fontFamily } = require('tailwindcss/defaultTheme');

module.exports = {
  // Use next/font for automatic optimization
};

// app/layout.tsx
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap', // prevent FOIT
  variable: '--font-inter',
  preload: true,
  fallback: ['system-ui', 'arial'],
});

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}

Critical CSS Inlining

// _document.tsx
import Document, { Html, Head, Main, NextScript } from 'next/document';

class MyDocument extends Document {
  render() {
    return (
      <Html>
        <Head>
          {/* Inline critical CSS */}
          <style dangerouslySetInnerHTML={{
            __html: `
              body { margin: 0; font-family: system-ui; }
              .hero { min-height: 100vh; }
              /* ... critical styles ... */
            `
          }} />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

Final Results

Final Performance Metrics
  • First Contentful Paint: 0.6s (from 2.1s, 71% improvement)
  • Largest Contentful Paint: 0.9s (from 3.2s, 72% improvement)
  • Time to Interactive: 1.1s (from 4.8s, 77% improvement)
  • Total Blocking Time: 120ms (from 890ms, 87% improvement)
  • Cumulative Layout Shift: 0.02 (from 0.15, 87% improvement)
  • Total Bundle Size: 192KB (from 847KB, 77% reduction)
  • Lighthouse Score: 98/100 (from 62/100)

Performance Monitoring in Production

// lib/monitoring.ts
import { onCLS, onFID, onFCP, onLCP, onTTFB } from 'web-vitals';

function sendToAnalytics(metric) {
  // Send to your analytics provider
  const body = JSON.stringify(metric);
  (navigator.sendBeacon && navigator.sendBeacon('/analytics', body)) ||
    fetch('/analytics', { body, method: 'POST', keepalive: true });
}

// Monitor all Core Web Vitals
onCLS(sendToAnalytics);
onFID(sendToAnalytics);
onFCP(sendToAnalytics);
onLCP(sendToAnalytics);
onTTFB(sendToAnalytics);

Key Takeaways

Measure first, optimize second. Without baseline metrics, you're guessing. Set up real user monitoring before making changes.

Bundle size matters more than you think. Every KB of JavaScript delays interactivity. Audit dependencies ruthlessly.

Images are usually the biggest culprit. Use modern formats (AVIF, WebP), lazy loading, and responsive sizes.

Code splitting is non-negotiable. Don't ship code users don't need. Dynamic imports and route-based splitting pay massive dividends.

Performance is a feature. Users notice speed. Our conversion rate improved 23% after these optimizations.