You ship a feature, open the site to admire it, and it hangs. For about a second the screen sits there blank. On your laptop you would barely register it. Your user is on a mid-range phone and patchy 4G, where that second is long enough to leave, and you never see them do it.
Performance is not one switch you flip at the end. It is a handful of habits, and this is the map I use: the levers I reach for, each one linking to a deeper write-up.
Decide what fast means, in numbers
Three metrics carry the weight, because each maps to something a user feels.
LCP is loading: when the biggest above-the-fold element finishes rendering. Good is 2.5 seconds or less. CLS is visual stability, meaning how much the page jumps around while it loads, and good is 0.1 or less. INP is responsiveness across the whole visit, and good is 200 ms or less. INP replaced FID as a Core Web Vital in 2024, so if your mental model still has FID in it, update it.
Those three are the Core Web Vitals. Other metrics matter — FCP in particular — but they are diagnostics rather than the thing Google measures you on.
Measure both ways, because they answer different questions. Lighthouse in DevTools or PageSpeed Insights gives you lab data: controlled, repeatable, something you can test a single change against. Search Console's Core Web Vitals report gives you field data at the 75th percentile, which is what your users on real devices got. Lab tells you why. Field tells you whether it mattered.
Each has its own deep dive: First Contentful Paint, Largest Contentful Paint and Cumulative Layout Shift. Start with whichever one your field report says is worst, not whichever one is most interesting.
Ship less JavaScript
The biggest lever for a React app is sending the browser less JavaScript to download, parse and execute. Every kilobyte competes with your first paint and drags on INP.
App Router components are Server Components by default, rendering to HTML on the server and shipping zero JS. Keep them that way and push "use client" down to the interactive leaves rather than marking a whole page.
// ❌ the entire page ships as client JS just for one interaction
'use client';
export default function Page() { /* ...lots of static markup... */ }
// ✅ page stays a Server Component; only the button ships JS
export default function Page() {
return <><StaticContent /><LikeButton /></>; // LikeButton is 'use client'
}Anything below the fold or rarely opened — comment widgets, modals, charts — gets lazy-loaded so it stays off the critical path:
import dynamic from 'next/dynamic';
const CommentsWidget = dynamic(() => import('./CommentsWidget')); // loaded on demand, not at first paintServe static, cache deliberately
The fastest response is one that is already rendered. Static generation or ISR for content pages means the HTML comes off a CDN instead of being built per request, and one line opts a route in:
export const revalidate = 60; // ISR: serve static HTML from the edge, refresh in the backgroundWorth knowing that caching defaults flipped in Next.js 15. Fetches are no longer cached unless you ask, which inverts most of the advice written for 14, and Next.js 16's Cache Components change the model again. The four cache layers also interact in ways that surprise people, and knowing which one is serving a stale response is most of the debugging. I wrote that up separately: Next.js App Router caching.
The same instinct applies on the back end, which is how a 14 GiB Redis cache became 1.6 GiB with no downtime.
Images, which are usually the LCP element
next/image earns its keep: modern formats, responsive sizes, lazy-loading by default. Two rules carry most of the benefit. Put priority on the one above-the-fold image so it is not deferred, and always give explicit dimensions so the layout does not shift as it loads, which is a direct CLS win on top.
import Image from 'next/image';
// LCP image: priority (skip lazy-loading) + explicit dimensions (no layout shift)
<Image src="/hero.jpg" alt="…" width={1200} height={630} priority />Fonts block text from painting
Which means they quietly hurt both FCP and CLS, and almost nobody looks at them.
next/font handles most of it: self-hosting the files so there is no extra connection to Google, subsetting to the characters you use, reserving space to avoid shift. Add display: "swap" so text shows immediately in a fallback, and preload only the font on the critical path. A code or label font can wait.
import { Plus_Jakarta_Sans, Geist_Mono } from 'next/font/google';
const sans = Plus_Jakarta_Sans({ subsets: ['latin'], display: 'swap' }); // critical, preloaded
const mono = Geist_Mono({ subsets: ['latin'], display: 'swap', preload: false }); // not first paintThird-party scripts are code you do not control
Analytics, pixels, chat widgets, tag managers. By default they can block your first paint, and the vendor has no reason to care.
next/script with the right strategy fixes it. afterInteractive for things that should run soon but not before the page is usable, lazyOnload for low-priority widgets that can wait for idle. Neither blocks the initial render.
import Script from 'next/script';
// analytics: runs after the page is interactive, never blocks first paint
<Script src="https://www.googletagmanager.com/gtag/js?id=..." strategy="afterInteractive" />
// low-priority chat widget: waits for browser idle time
<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />Look at the bundle
You cannot trim what you cannot see. @next/bundle-analyzer gives you a treemap of what is in each bundle, and it is the fastest way to catch a large dependency that slipped into a client component. A date library, an icon set imported whole, a charting package dragged in by one small widget.
Wrap your config, build with ANALYZE=true, look for the big rectangles. Nine times out of ten the fix is a lighter dependency or moving the code to the server. I went through the whole process in what's in your Next.js bundle.
The loop, not the checklist
None of these levers is heroic alone. Server Components trim JS. next/image and next/font cut loading cost. Static rendering moves work to the edge. Deferred scripts stay out of the way. The analyzer keeps you honest.
What makes them work is applying them as a loop rather than running the list once. Measure to find the worst metric, fix one lever, measure again to confirm it helped and to see what is now the bottleneck, repeat.
That is duller than any single trick and it is the only thing I have seen keep a site fast as it grows, instead of regressing quietly release by release until someone files a ticket about it.