The setting: a Next.js 15 marketing site over its LCP budget
The project starts on a classic marketing site: a dozen-plus pages, a blog on MDX, a product team happy with the visual result. On the stack side, Next.js 15 App Router, deployed on Vercel, images served via next/image, fonts via next/font. On paper, everything is in order. The internal budget is clear: LCP under 1.5 seconds on mobile 4G (Motorola Moto G Power, Lighthouse mobile profile, 4x CPU throttling). Yet the mobile Lighthouse report shows 2.1 s at simulated p75. That is 600 ms over budget, and a Core Web Vitals rating sliding from "Good" to "Needs Improvement" on the home page.
The first reflex at this stage is not to open a terminal and yell at dependencies. You open three tabs: the Lighthouse report, the CrUX field data (when available), and the list of LCP elements identified by Lighthouse. In 80% of our marketing projects, there is no single culprit: it is an accumulation of 5 to 10 micro technical decisions that, added up, blow the budget. Here, we are hunting a 600 ms delta — roughly 50 to 80 KB of blocking or badly prioritised resources. Let the byte hunt begin.
The diagnostic method: three tools, in this order
Before touching code, we always run the same frame. Step 1: Chrome DevTools, Performance Insights tab (not the old Performance panel, which is too low-level for this stage). Reload the page with 4x CPU throttling + Fast 3G network, watch the LCP marker, and note the blocks going from grey (idle) to red (main thread blocked). Step 2: WebPageTest, launch a run from a real Moto G Power (Dulles, Virginia), and pull the frame-by-frame filmstrip: at which second does the hero become visible? Does the font render before or after the LCP image?
Step 3: Lighthouse, open the report's "Treemap" view (View Treemap). It is the most eloquent tool for a non-technical audience: each rectangle represents a JS chunk, its size proportional to bytes transferred, and the colour indicating the used / unused code ratio at load. On this project, the treemap showed three glaring red blocks: a vendor.js at 47% unused, a fonts.css at 60% unused, and a framer-motion chunk at 0% used on the home page. Finally, the Sources > Coverage tab confirms: 312 KB of JS loaded, of which 178 KB unused at first render. The diagnosis took less than 20 minutes.
The 7 usual suspects (and which one was really guilty)
On Next.js marketing projects, you almost always find the same waste profiles. Suspect #1: SVG icons imported as React components. The project used lucide-react with `import { Icon } from "lucide-react"` but also an in-house library of 30 inline SVGs (1.2 KB each), all included in the bundle because they were resolved at compile time via an index.ts barrel file. Result: 36 KB of icons of which 28 were not even referenced on the home page.
Suspect #2: date-fns and its locales. An `import { fr } from "date-fns/locale"` had slipped into a publication-date component. Except date-fns v3 imports the entire FR locale (days, months, localised formats, relative formats): 15 KB for a simple `format(date, "d MMMM yyyy")`. Suspect #3: Framer Motion. The agency had loaded the library (45 KB gzipped) for a single hero animation — a 200 ms fade-in. The same animation in pure CSS costs 0 KB. Suspect #4: the CMS-driven hero image, with a `sizes="100vw"` attribute instead of `sizes="(max-width: 768px) 100vw, 50vw"`. next/image was therefore serving a 2400-px-wide variant to a 360-px mobile viewport.
Suspect #5: the non-subset webfont. The project used an editorial font (200 KB WOFF2) declared via next/font, but without the latin subset: all the Cyrillic, Greek, and mathematical symbols were embedded. Suspect #6: the consent banner (Axeptio) loaded synchronously in the `<head>`, with a 15 KB script blocking the parser for 120 ms. Suspect #7: GTM (Google Tag Manager) with a container of 14 tags fired on PageView, of which only 3 were genuinely necessary (GA4, Hotjar, Meta Pixel). The other 11 (LinkedIn Insight, Pinterest, Quora, TikTok, etc.) were residue from past campaigns.
On this project, the real culprits behind the 600 ms delta were suspects #4 (image), #5 (font), and #6 (consent). But every suspect deserved a cleanup: we treated all seven.
The correction sequence, ordered by impact
You always attack the biggest gain first, not the easiest. The real impact order on this project: (1) Font subsetting via next/font with `subsets: ["latin"]` and `display: "swap"`: -90 KB of WOFF2 transferred. The editorial font drops from 200 KB to 28 KB. (2) Icon tree-shaking: replacing the barrel file with explicit named imports `import { ChevronRight } from "lucide-react"`, and removing the in-house library in favour of the already-loaded lucide-react. Bundle gain: -30 KB.
(3) Asynchronous GTM loading: moving `<script src="gtm.js">` to `async`, and auditing the 14 tags to keep only 3. Gain on the blocking path: -25 KB, i.e. ~80 ms less TBT. (4) Fixing the hero's `sizes` attribute: `sizes="(max-width: 768px) 92vw, (max-width: 1536px) 50vw, 33vw"`. next/image now serves a 640-px variant instead of 2400 px. Transfer gain: -180 KB per mobile page view. (5) Deferring the consent banner: `next/script` with `strategy: "lazyOnload"`, and removal from the `<head>`. Gain: -15 KB on the blocking path.
Project total: about 340 KB fewer transfers on the mobile home page, of which 130 KB on the critical path (first render). The simulated Lighthouse LCP drops from 2.1 s to 0.9 s. Total Blocking Time drops from 280 ms to 90 ms. CLS stays stable at 0.02. All Core Web Vitals go green.
The measurement loop: Lighthouse, CrUX, Sentry
A performance project without a measurement loop is not a performance project: it is a hunch. We run three levels. Level 1 (lab data): mobile Lighthouse before/after, 5 consecutive runs in private browsing, take the median. Here: LCP 2.1 s → 0.9 s, TBT 280 ms → 90 ms, FCP 1.4 s → 0.7 s. Level 2 (field data): CrUX via the PageSpeed Insights API, comparing the 28 previous days with the 28 following days. The real p75 LCP drops from 2.4 s to 1.3 s. That is the metric that counts for Google and for real users: it alone triggers the "Good URL" signal.
Level 3 (continuous monitoring): we connect Sentry Performance to the site for 7 days, with the @sentry/nextjs SDK. We watch the p50, p75, p90 percentiles of LCP on Android users (segmented by device). We verify that no regression appears on the modified pages, and that "slow" sessions (LCP > 2.5 s) fall from 14% to 2%. After 7 days without an alert, the project is validated and the performance budget is updated in the repo's budget.json file: `LCP_MOBILE_MAX = 1500` ms, `JS_BUNDLE_MAX = 180` KB gzipped.
The lessons that generalise
First lesson: on a mid-range mobile 4G device (Moto G Power), 50 KB of blocking JS represents roughly 200 ms of LCP. The ratio is not linear: beyond 170 KB of total JS, the main thread saturates and every extra KB costs 5 to 8 ms. Miller's curve applies. Second lesson: the Lighthouse treemap is the most powerful pedagogical tool for explaining performance to a non-technical client. An 80-KB unused red rectangle speaks louder than a lecture on tree-shaking.
Third lesson: unused code is the silent budget killer. It crashes nothing, raises no Sentry error, and shows up in no visual QA. But it accumulates: a forgotten import here, a dev dependency shipped to prod there, a residual GTM tag from a 2023 campaign. The only defence is a versioned budget.json, a CI check via bundle-analyzer, and a quarterly dependency audit. Fourth lesson: fonts are almost always under-optimised. next/font handles the latin subset by default, but many projects load third-party editorial fonts without `subsets` or `unicode-range`. That is a free 80 to 150 KB gain.
Last lesson, and the most important for a studio: performance is not a one-shot deliverable, it is an ongoing contract. On this project, six weeks later, a PR reintroduced Framer Motion for a hover animation on cards. The budget.json triggered a CI warning, and the PR was adjusted. Without that guardrail, we would have lost 45 KB again within two months. Discipline is what separates a site that stays fast from a site that regains weight.