The first mistake people make with performance optimization is jumping straight to fixes without measuring. Run Lighthouse in Chrome DevTools on a production build (not dev mode — dev builds are always slow). Note the specific metrics: LCP, FID/INP, CLS, and TBT. These tell you where to focus.
On this project, the main issues were: LCP of 6.2s (target: under 2.5s), a 4.2MB JavaScript bundle, and unoptimized images averaging 800KB each. Those three issues caused 90% of the score gap.
A 4.2MB bundle on first load is the #1 killer of performance. The fix: code splitting and lazy loading. In Next.js, dynamic() imports defer loading of components until they're needed:
import dynamic from 'next/dynamic';
// Heavy chart library — only loads when the analytics page is visited
const AnalyticsChart = dynamic(() => import('@/components/AnalyticsChart'), {
loading: () => <ChartSkeleton />,
ssr: false,
});We moved from one 4.2MB bundle to a 280KB initial bundle with lazy-loaded chunks. This alone dropped LCP by 2.8 seconds.
The platform had 5,000+ recipe images stored on S3, served as raw JPEGs. We migrated to Cloudflare R2 with automatic WebP conversion and responsive sizing. The setup:
Average image size dropped from 800KB to 45KB. Media load time reduced by 40%.
Recipe pages are read-heavy and change infrequently. Switching from SSR (rendered on every request) to ISR (Incremental Static Regeneration — rendered once, cached, re-generated every hour) eliminated server rendering time from the LCP calculation. The page was served as a static HTML file from Cloudflare's cache.
// Next.js 14 App Router ISR export const revalidate = 3600; // re-generate every hour
CLS (Cumulative Layout Shift) was 0.18 — above the 0.1 target. Two causes: images without explicit dimensions (browser doesn't reserve space) and web fonts loading after text renders. Fixes: always set width and height on images, and use font-display: optional for non-critical fonts. CLS dropped to 0.02.