Debugging LCP on a Real Site - A Case Study on marcofaul.de
A first-person walkthrough of fixing LCP on this very site - a JS reveal animation gating first paint, a font preload that held rendering hostage, a 5.8s image CDN cold start, and the measurement noise that almost sent me chasing ghosts.

I write about performance for clients all the time — Shopware server-side tuning, INP architecture work. So it was mildly embarrassing when I audited my own site and PageSpeed Insights reported a mobile LCP north of 4 seconds on the homepage. A static, prerendered Nuxt site. Served from a CDN. With one hero heading as the LCP element.
This post is the full debugging story: what the reports said, which findings turned out to be noise, and the three real root causes I found and fixed — with the actual code. If you've ever stared at a red LCP score on a site that should be fast, this is the methodical version of that panic.
The Setup
marcofaul.de is a fully prerendered Nuxt site (netlify-static preset) behind Netlify's CDN and Cloudflare. There is no server rendering at request time, no database, no API calls on the critical path. TTFB in real traces sits between 0.1 and 0.65s. By every architectural argument, LCP should be trivially green.
It wasn't. PSI showed mobile LCP around 4.5s on the homepage and 5.4–5.9s on blog posts. Time to find out why.
Step Zero: Separate Signal from Noise
Before touching any code, I ran repeated PSI audits against the same deployed commit — same code, same pages, four runs. Here's what came back for two pages:
| Page | Run 1 | Run 2 | Run 3 | Run 4 |
|---|---|---|---|---|
| /portfolio | 3901ms | 3901ms | 3901ms | 975ms |
| /contact | 2776ms | 7126ms | 4126ms | — |
Same bytes, same infrastructure — and the contact page swings by over 4 seconds between runs. On this site, single-run lab variance is roughly ±3 seconds. That number matters more than any individual score, because it defines the minimum bar for believing anything: a "regression" or "improvement" smaller than the noise floor is a coin flip.
Two practical rules fell out of this:
- Never act on a single run. Compare medians across at least 3–4 runs of the same commit.
- Never measure right after a deploy. Every Netlify deploy invalidates the edge cache, so a crawl immediately after deploying measures cold assets and inflates LCP. I now warm the key pages and image variants with
curlbefore any verification run.
There's a third caveat specific to small sites: no field data. marcofaul.de doesn't get enough Chrome traffic to appear in CrUX, so lab data is all there is. That makes the noise discipline non-negotiable — there's no 28-day field trend to sanity-check against.
With the noise floor established, three findings survived the median test. All three turned out to be real.
Root Cause 1: A Reveal Animation Was Gating First Paint
The homepage hero — the LCP element — was wrapped in a UiReveal component: a small v-motion wrapper that fades content in with a stagger. Standard portfolio-site polish.
Here's the trap. The HTML is server-rendered, so the hero text is in the document from byte one. But v-motion applies initial: { opacity: 0 } during hydration — and hydration can run before the browser's first paint. The sequence on a cold mobile load was:
- HTML arrives, hero text is present and would be paintable
- JS bundle downloads, parses, hydrates — before first paint happens
- Hydration sets the hero to
opacity: 0 - The reveal animation plays
- Only now does the text actually paint
The SSR'd content was invisible until the entire JavaScript chain finished. FCP and LCP were gated not on network or rendering, but on hydration plus an animation timeline. In the traces this showed up as an FCP of 1124ms that dropped to 170ms once the gate was removed — on a page where the text was sitting in the HTML the whole time.
The fix was deliberately boring — the hero renders statically, no wrapper:
<!-- No UiReveal above the fold: v-motion hides the SSR-visible text until
hydration + animation finish, which gates FCP/LCP on the JS chain.
Below-fold sections keep their reveals. -->
<UiBadge variant="neutral" pulse class="mb-6">Available for projects</UiBadge>
<h1 class="...">
Web experiences that <span>ship and scale.</span>
</h1>
<p class="...">{{ hero.lede }}</p>
Below-the-fold sections keep their reveal animations — by the time a user scrolls there, hydration is long done and the animation costs nothing. The rule I encoded in a comment right in the component: nothing above the fold gets wrapped in a reveal. The comment is there because I know future-me will absolutely try to re-add it.
This is worth generalizing: any entrance animation library that starts elements at opacity: 0 (v-motion, AOS, Framer Motion's initial, GSAP from tweens) turns your SSR head start into a liability. You paid for server rendering and then hid the result behind your bundle.
Root Cause 2: Font Preload + font-display: optional Held the Render
The site uses Inter with font-display: optional. I chose optional deliberately in an earlier round: with swap, the mid-load font swap re-wrapped the large headings and produced a full-viewport layout shift — CLS around 0.9 on blog posts. optional fixes that by contract: the font is used only if it's ready at first paint, and never swapped in afterwards.
But I had also left preload: true on the font — the conventional wisdom that you should always preload your critical webfont. The combination backfired: Chrome holds first render waiting for a preloaded optional font. The browser sees the preload, knows the font is wanted at first paint, and delays rendering to give it a chance to arrive. In PSI this showed up as roughly 1.3 seconds of element render delay on every single page — the hero text just sitting there, unpainted, waiting for a font that optional semantics say we're happy to live without.
The fix is one line in nuxt.config.ts:
fonts: {
families: [
{
name: 'Inter',
provider: 'google',
weights: [400, 500, 540, 600, 700],
display: 'optional',
// No preload: Chrome holds first render for preloaded optional fonts
// (~1.3s LCP element render delay on PSI). Without it, the fallback
// paints immediately — which is what 'optional' is for anyway.
preload: false,
},
],
},
Without the preload, the metric-adjusted fallback font paints immediately on cold loads, and warm loads (font already in cache) get Inter at first paint. That is exactly the trade optional was designed for — the preload was fighting it.
The lesson: font loading directives interact. preload and font-display aren't independent knobs; each is a defensible choice alone, and together they produced the worst outcome. If you use optional, don't preload. If you preload, you're implicitly saying the font is worth delaying render for — so own that.
Root Cause 3: The Image CDN's Cold Start Was the Blog LCP
Blog posts were the worst pages in every report, and here the LCP element is the cover image. The waterfall showed why: on Netlify, @nuxt/image silently switches its provider to Netlify's runtime image CDN, rewriting every image URL to /.netlify/images?url=...&w=.... Transforms happen on demand — and a cold transform took 5.8 seconds. First visitor after a deploy (or any cache eviction) eats a nearly-six-second image request as their LCP.
Runtime image CDNs are great when you have thousands of images and unpredictable size requirements. On a prerendered site with a known, finite set of pages, they're pure downside: every variant that will ever be requested is knowable at build time.
So I pinned the provider back to ipx and let the prerenderer bake every variant into the build output as static files:
image: {
// Force ipx: on Netlify @nuxt/image auto-switches to the runtime image
// CDN (/.netlify/images?...), whose cold transforms took ~5.8s and were
// the blog pages' LCP. ipx variants are prerendered to static /_ipx files.
provider: 'ipx',
// Re-encode variants at q70 (sources are ~q80): blog hero bytes are the
// simulated-mobile LCP floor on article pages.
quality: 70,
},
Now every responsive variant ships as a plain static file under /_ipx/s_WxH/..., edge-cached like any other asset. No runtime transforms, no cold starts, no surprise provider switch. The quality: 70 re-encode came later, once traces showed that raw cover-image bytes on simulated 4G had become the remaining floor on article pages.
The meta-lesson stings a little: the framework changed my image pipeline based on the deploy target, and I only found out from a waterfall. Zero-config provider detection is convenient right up until it isn't — check what URLs your images actually resolve to in production.
The Fix That Didn't Move the Needle
Honesty section. PSI's opportunity list attributed ~2.35s of mobile LCP to two render-blocking CSS requests, so I inlined all styles into the prerendered HTML (features: { inlineStyles: true } plus inlining the remaining stylesheet links in a prerender:generate hook). Textbook fix, cleanly implemented — twice, actually, because the first attempt regressed font-display and had to be reverted.
Measured result across multiple runs: no LCP improvement. The render-blocking CSS was never the real gate — the reveal animation and the font preload were, and PSI's attribution model just didn't know that. I kept the inlining because it's harmless and silences the audit, but I'm under no illusion it earned its complexity.
This is the quiet failure mode of Lighthouse's opportunity estimates: they're computed per-audit in isolation, assuming everything else stays fixed. When the actual bottleneck is something the tool can't model — a hydration-time opacity gate, a preload/font-display interaction — the estimates confidently point you at the wrong thing.
Where It Landed
After the three fixes, observed traces on blog posts look like this: TTFB 42ms, image load 575ms, render 568ms — call it ~1.3s of actual LCP work. PSI's simulated mobile score for the same pages still reports 5.4–5.9s, because simulated 4G throttling plus simulation overhead dominates once the real problems are gone. Both numbers are true; they're just answering different questions. Knowing which one you're optimizing for is half the job.
Takeaways
- Establish your noise floor first. Run the same commit 3–4 times before believing anything. On this site the lab variance is ±3s — most "regressions" I could have chased were smaller than that.
- SSR HTML you hide behind hydration is SSR you wasted. Entrance animations that start at
opacity: 0gate FCP/LCP on your JS bundle. Keep them below the fold. preload+font-display: optionalis a trap. Chrome holds first render for preloaded optional fonts. Pick one philosophy and commit.- Know what your image URLs resolve to in production. Auto-detected providers can put a runtime service with multi-second cold starts on your critical path — on a prerendered site, static variants win.
- Distrust single-audit attribution. PSI's 2.35s CSS estimate produced a zero-impact fix; the real causes never appeared in the opportunity list as such.
- Measure warm, compare medians, write down what you learn — preferably as comments next to the config lines, where the next refactor will actually see them.
None of this required exotic tooling — repeated PSI runs, DevTools traces, and a willingness to disbelieve the first number on the screen. The same process applies whether it's a portfolio site or a Shopware storefront doing eight figures; only the stakes change.
Fighting an LCP score that doesn't match how fast your site should be? Get in touch — untangling exactly this kind of thing is a big part of what I do.
Useful References
Enjoyed this?
Get new posts as they land.
Keep reading

Purging Cloudflare's Cache Automatically on Every Netlify Deploy
This site sits on Netlify behind Cloudflare, and after every deploy Cloudflare kept serving stale HTML. Here's why the Netlify-CDN-Cache-Control header was a dead end for static files, and the 26-line build plugin that purges Cloudflare's edge cache the moment a deploy goes live — including the CJS/ESM gotcha that broke the first build.

Core Web Vitals 2026 - How to Master INP (Interaction to Next Paint)
INP is the Core Web Vital most sites fail. Learn what INP measures, the 200ms threshold, and concrete JavaScript techniques to make your store feel instant.

Shopware 6 Developer Performance Guide - Essential Improvements and Fixes
Performance optimization is crucial for any e-commerce platform. Here are the most impactful performance improvements and fixes for Shopware 6 developers.