EmpireUI
Get Pro
← Blog9 min read#lcp#performance#images

LCP Optimization Guide: Images, Fonts, Server Response and Cache

Everything you need to hit sub-2.5s LCP: image formats, font loading strategies, TTFB reduction, and caching headers that actually work.

Dashboard screen showing web performance metrics and loading speed

What LCP Actually Measures (and Why You're Probably Reading It Wrong)

LCP — Largest Contentful Paint — measures when the biggest visible element in the viewport finishes rendering. Not when the DOM is ready, not when JS executes, not when your spinner disappears. The *largest element*. In practice that's almost always a hero image, an H1, or a background image that someone made a background-image CSS property instead of an <img> tag (which makes it invisible to the browser's preload scanner — more on that later).

Google's threshold is 2.5 seconds for a "Good" score. Anything past 4s is "Poor" and you're losing rankings. But here's what most devs miss: LCP is measured at the 75th percentile of real-user visits, not your fast MacBook on fiber. That means your 1.2s local measurement can easily translate to 3.8s for a user on a mid-range Android in the American Midwest.

Worth noting: LCP replaced the old First Meaningful Paint metric in 2020 precisely because FMP was notoriously unreliable. LCP correlates much better with perceived load time — users actually care when the main content appears, not when the browser technically started painting pixels.

Before touching any code, open Chrome DevTools → Performance tab → run a profile on a throttled connection (Fast 4G at minimum). The flame chart will tell you exactly which element is your LCP candidate and what's blocking it. Do that first. Every time. Blindly "optimizing" without measurement is how you spend a week on image compression when your actual bottleneck is a render-blocking Google Fonts request.

Image Optimization: Format, Size, and the Preload Scanner

Images are the LCP element in the vast majority of sites — somewhere around 70% based on CrUX data from 2024. So let's start here. The format conversation is mostly settled: AVIF first, WebP fallback, JPEG/PNG as last resort. AVIF can be 40–50% smaller than WebP at equivalent visual quality, which on a 1200px hero image can easily shave 150–200KB off your initial load.

<picture>
  <source srcset="/hero.avif" type="image/avif" />
  <source srcset="/hero.webp" type="image/webp" />
  <img
    src="/hero.jpg"
    alt="Hero image"
    width="1200"
    height="630"
    fetchpriority="high"
    loading="eager"
  />
</picture>

That fetchpriority="high" attribute is not optional for your LCP image — it's the single highest-impact single-attribute change you can make. Without it, the browser treats your hero image with the same priority as every other image on the page. With it, the browser's network stack jumps it to the front of the queue. Landed in Chrome 102 and Safari 17.2, so browser support is solid as of 2026.

Honestly, the biggest mistake I see is background-image in CSS for hero content. The browser's preload scanner — the lightweight parser that runs ahead of the main HTML parser — can't see CSS background images. It only picks up <img src> and <link rel=preload> in the HTML document. So if your hero is a div with background-image: url('/hero.jpg'), the browser won't even start fetching it until CSSOM is built. That's easily 400–800ms wasted on a typical site.

Sizing matters too. Serve images at the rendered size, not the original camera resolution. A hero that renders at 1200px wide should have a 1200px (and maybe 2400px for retina) variant — not a 4000px original scaled down with CSS. Use srcset with width descriptors and let the browser pick. If you're on Next.js, the <Image> component handles this automatically with the sizes prop.

Font Loading: The Hidden LCP Killer

Fonts don't directly affect which element is your LCP candidate, but they absolutely affect *when* that element renders. If your LCP is an H1 or any text node, and that text uses a custom font that hasn't loaded yet, the browser will either show nothing (FOIT — Flash of Invisible Text) or show fallback text (FOUT — Flash of Unstyled Text), and neither counts as a real LCP paint until the actual font renders.

The fastest font strategy in 2026 is font-display: optional. It tells the browser: if the font isn't cached and available within ~100ms, skip it entirely and use the system fallback. No FOIT, no late swap. Your LCP fires immediately. The trade-off is that first-time visitors see your fallback font, which is fine if you've done the work of matching metrics.

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;
  font-display: optional;
  font-style: normal;
}

If you can't live with optional, use swap — but then you need to match your fallback font's metrics to minimize layout shift on swap. The size-adjust, ascent-override, descent-override, and line-gap-override descriptors in @font-face exist exactly for this. Tools like fontaine or Next.js's built-in font optimization generate these overrides automatically.

One more thing — preload your font file. A <link rel="preload" as="font"> in the <head> tells the browser to fetch the font at high priority before it even encounters the @font-face rule. Without it, font fetching starts only when the CSS is parsed and the browser realizes it needs the font. That's late. Too late.

Server Response Time: TTFB Is Your Foundation

Every millisecond of Time to First Byte (TTFB) directly adds to your LCP. The browser can't start rendering anything — not images, not fonts, not text — until it receives the first bytes of HTML. Google considers sub-800ms TTFB as "Good". If you're above that, no amount of image optimization will save your LCP score.

For Next.js apps, the biggest TTFB wins come from moving computation out of the critical path. If your server-side rendering is waiting on a database query, a third-party API call, or a slow ORM layer before sending the first byte, you're punishing every user who visits. Quick aside: Next.js 14+ streaming with React Server Components lets you Suspense-wrap slow data fetches so the shell HTML (which contains your LCP candidate) streams immediately.

// Stream the shell immediately, defer slow data
export default function Page() {
  return (
    <>
      {/* This renders and streams right away */}
      <HeroSection />
      {/* This can be slow — shell already sent */}
      <Suspense fallback={<ProductSkeleton />}>
        <ProductGrid />
      </Suspense>
    </>
  );
}

Edge runtime helps too, but only if your data is close to the edge. Running your Next.js app on Vercel Edge or Cloudflare Workers cuts round-trip latency significantly for globally distributed users — but if you're still hitting a single Postgres instance in us-east-1 from an edge worker in Frankfurt, you've just added latency instead of removing it. Co-locate your cache layer (Redis, KV store) at the edge and reserve the database round-trip for cache misses.

Look, if you're on a VPS or self-hosted setup, the first thing to check is whether you have any compression middleware enabled. gzip or brotli on your HTML responses is free TTFB improvement — brotli typically achieves 15–25% better compression than gzip on text content, and every modern browser supports it.

Cache Strategy: The Headers That Actually Move the Needle

Caching is where most frontend devs drop the ball — not because they don't know it matters, but because HTTP cache semantics are genuinely confusing. Let's be direct about what you actually need. Static assets (JS bundles, CSS, fonts, images with content-hashed filenames) should get Cache-Control: public, max-age=31536000, immutable. One year. The immutable hint tells browsers not to bother revalidating during that period even if the user hard-refreshes.

# Nginx config for content-hashed static assets
location ~* \.(js|css|woff2)$ {
  add_header Cache-Control "public, max-age=31536000, immutable";
  add_header Vary "Accept-Encoding";
}

# HTML documents — short cache with revalidation
location ~* \.html$ {
  add_header Cache-Control "public, max-age=0, must-revalidate";
  add_header ETag "on";
}

HTML documents are a different story. You want users to always get fresh HTML so you can ship updates, but you also want the CDN to cache the response and serve it from the edge. The pattern that works: Cache-Control: public, s-maxage=86400, stale-while-revalidate=86400 — CDN caches for 24 hours, serves stale while revalidating in the background. End users never wait for the origin.

For images that aren't content-hashed (user-uploaded content, CMS images), use Cache-Control: public, max-age=604800, stale-while-revalidate=86400 — one week fresh, one day stale. Add Vary: Accept if you're serving AVIF/WebP based on the Accept header so CDN nodes don't serve the wrong format to clients.

In practice, the fastest LCP you can possibly get is a CDN cache hit for your HTML document and a browser cache hit for your LCP image — at that point you're looking at sub-100ms for both. That's the target. Everything else is getting you closer to that state. If you're building UIs with components from Empire UI's glassmorphism components or other visual-heavy sections, these caching rules matter even more since those pages tend to load more assets.

Resource Hints and the Critical Path

Once TTFB and caching are sorted, your next lever is shaping what the browser prioritizes in the first few hundred milliseconds. Resource hints — preload, preconnect, and dns-prefetch — let you influence the browser's loading order before it would naturally discover those resources.

<head>
  <!-- Preconnect to critical third-party origins -->
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  <link rel="dns-prefetch" href="https://cdn.yourdomain.com" />

  <!-- Preload your LCP image -->
  <link
    rel="preload"
    as="image"
    href="/hero.avif"
    imagesrcset="/hero-640.avif 640w, /hero-1200.avif 1200w"
    imagesizes="100vw"
    type="image/avif"
    fetchpriority="high"
  />

  <!-- Preload critical font -->
  <link
    rel="preload"
    as="font"
    href="/fonts/inter-var.woff2"
    type="font/woff2"
    crossorigin
  />
</head>

The imagesrcset and imagesizes attributes on <link rel="preload"> are relatively new — they landed in Chrome 88 in 2021 and tell the browser which variant to preload based on the viewport, matching the srcset logic of the actual <img> element. Without them, your preload might fetch the 1200px image on a 375px phone. That wastes bandwidth and can actually hurt LCP on mobile.

Be conservative with preload. Over-preloading is a real problem — every resource you mark as preload competes with your LCP asset for bandwidth. Only preload the LCP image and the critical font. Everything else can wait or use prefetch for future navigations. If you're building an app with heavy animations using something like the components at Empire UI, consider whether those animation assets are truly on the critical path for first paint.

One practical tip: run Lighthouse in CI and fail builds if LCP regresses above a threshold. You can automate this with the Lighthouse CLI — lighthouse https://yoursite.com --only-categories=performance --output=json | jq '.categories.performance.score' and exit 1 if it drops below 0.9. Catching regressions at merge time is infinitely easier than debugging them post-deploy.

Putting It Together: A Real Optimization Checklist

Here's the order of operations that actually works, in order of typical impact. First: identify your LCP element using a real field data tool (CrUX, PageSpeed Insights, or web-vitals JS). Don't guess. Second: if it's an image, convert to AVIF/WebP, add fetchpriority="high", and make sure it's an <img> tag not a CSS background. Third: add <link rel="preload"> for the LCP image and your critical font in the <head>. Fourth: measure TTFB — if it's above 800ms, fix your server or add a CDN before doing anything else.

# Quick field-data check using PageSpeed Insights API
curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://yoursite.com&strategy=mobile&key=YOUR_KEY" \
  | jq '.loadingExperience.metrics.LARGEST_CONTENTFUL_PAINT_MS'

Fifth: audit your Cache-Control headers. Run curl -I https://yoursite.com/hero.avif and check what's there. If you see cache-control: no-store or nothing at all, you're fetching that image from origin on every visit. Sixth: add font-display: optional or font-display: swap with metric matching to your @font-face declarations. Seventh: if you're on Next.js, check that your LCP-containing page streams the HTML shell without blocking on data fetches.

That said, LCP optimization isn't a one-time thing. Shipping a new hero image in a non-optimized format, adding a new Google Fonts import without thinking, or a new A/B test that swaps the hero component — any of these can blow up your LCP overnight. Automated regression testing in CI is the only sustainable answer. The gradient generator and box shadow generator tools on Empire UI are good examples of pages where careful asset loading pays off — lots of visual assets, interactive controls, and users who expect snappy responses.

In practice, teams that hit sub-2s LCP consistently aren't doing anything magical. They've got a CDN in front of everything, content-hashed assets with long cache TTLs, AVIF images with fetchpriority="high" on their LCP candidate, and a CI gate that fails on performance regressions. That's it. None of it is hard — it just needs to actually be done.

FAQ

Does LCP only count images, or can it be text?

LCP counts whichever visible element is largest in the viewport — that can be an image, a video poster frame, a background image loaded via CSS (with caveats), or a block of text like an H1. Text-as-LCP is actually great because text renders faster than images, but custom fonts can delay it significantly if font-display isn't set correctly.

Does `fetchpriority="high"` work in all browsers?

As of 2026 it works in Chrome, Edge, and Safari 17.2+. Firefox doesn't support the attribute yet but it's in their roadmap. In unsupported browsers the attribute is ignored safely — it doesn't break anything, the browser just uses default priority. Still worth adding for the 80%+ of users on supported browsers.

My LCP score is good in Lighthouse but poor in CrUX — why?

Lighthouse runs in a controlled lab environment on a fast connection from a single location. CrUX is real user data across 28 days, all devices, all network conditions. The 75th percentile user is on a much slower device and connection than your test setup. Always chase your field data (CrUX), not your lab score.

Should I self-host Google Fonts or use the CDN?

Self-host, full stop. The Google Fonts CDN used to benefit from cross-site caching, but browsers partitioned caches in 2020 so that advantage is gone. Self-hosting gives you control over font-display, lets you subset the font file, removes the extra DNS lookup and connection to fonts.gstatic.com, and typically cuts font load time by 50–100ms.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

CLS Optimization Guide: Aspect Ratios, Font Fallbacks, Ad SlotsCore Web Vitals in 2026: LCP, CLS, INP — Measure and FixNext.js Image Optimisation: next/image Deep Dive — Every Prop ExplainedImage Optimisation in 2026: WebP, AVIF, Lazy Load and LCP