Performance EngineeringJune 13, 2026·Skaitymo trukmė – 6 min.·Miracle KaluMiracle Kalu

How we cut LCP from 4.5s to under 2.5s on a Next.js 16 editorial site

Rows of server racks in a modern data center with blue lighting

The LCP moved, and that was the first clue

A few months back I joined a performance rescue on a high-traffic editorial site running Next.js 16 App Router, Turbopack, and a self-hosted Docker setup. Lighthouse performance was 79. LCP was 4.5 seconds. TTFB was 1.7 seconds.

The LCP element was the JW Player widget embedded at the top of the article. That mattered because a video player is one of the worst possible LCP candidates: it needs an iframe, a heavy script, a poster image, and often an autoplaying stream before the browser considers it rendered. Chrome measures LCP for <video> at the first rendered frame or the poster image. Either way, you are fighting the player's load order, not just your own HTML.

We did not start by compressing images. We started by stopping the player from competing for the initial paint.

Why video players break LCP

Editorial sites drop video players near the top of articles because video views matter. The browser treats that player as the largest visible element, and LCP cannot fire until the script downloads, executes, and renders the first frame or poster image.

On a slow connection, jwplayer.js and its dependencies can cost 400–800 ms of main-thread execution before anything appears. If the player autoplays, the browser also fetches video segments. If it uses a poster, that poster becomes the LCP candidate and must be fetched with high priority. Most implementations do neither well.

The fix is a facade: render a lightweight placeholder on the server, then load the real player only on user interaction or after the first user signal. Chrome's LCP settles on your static placeholder, and the full player pays its cost later.

// sanitizedContent.ts
export function lazifyJwPlayer(html: string) {
  return html.replace(
    /<iframe[^>]+src="(https:\/\/cdn\.jwplayer\.com\/players\/[^"]+)"[^>]*><\/iframe>/g,
    `<div class="jw-facade" data-src="$1" style="aspect-ratio:16/9;background:#000">
       <button aria-label="Load video player">▶</button>
     </div>`
  );
}

We stopped loading the JW Player script on initial page load. The facade rendered a black 16:9 box with a play button. That single change dropped LCP from 4.5 s to roughly 2.8 s.

Then the headline became the LCP

Once the player was deferred, the LCP element shifted to the article headline. That sounds like progress — text is small and fast — but the headline lived inside ArticleHero, a client component. Because of the 'use client' boundary, the <h1> only rendered after React downloaded, parsed, and hydrated the page bundle. On a mid-range phone over slow 4G that added roughly 800–1200 ms to LCP.

The fix was to make ArticleHero a server component and extract the interactive bits into a tiny client island. In practice that meant untangling a 354-line navbar, a theme context provider that wrapped the entire <body>, and analytics scripts that all believed they needed to run before the user saw anything.

I narrowed the context boundary to only wrap {children} inside <main>. I dynamically imported the navbar with a 60 px placeholder. I moved FundingChoices, ad loaders, and the footer out of the initial hydration path. The headline became server-rendered HTML.

Caching: the other half of TTFB

TTFB at 1.7 s was not a capacity problem; it was a caching problem. The category pages had export const dynamic = 'force-dynamic', which disabled every layer of caching Next.js provides.

I replaced it with export const revalidate = 60 and aligned unstable_cache TTLs to the same window. The key detail was fixing the cache key. Two callers passed the same slug with different leading-slash formatting, so both missed the cache and ran the full backend request independently. Normalizing to posts-by-slug-${cleanedSlug} removed that duplicate work.

When self-hosting behind Cloudflare, the CDN may ignore or override Cache-Control. I added explicit CDN directives:

// next.config.ts
async headers() {
  return [
    {
      source: "/_next/static/chunks/:path*",
      headers: [
        { key: "Cache-Control", value: "public, max-age=31536000, immutable" },
        { key: "CDN-Cache-Control", value: "public, max-age=31536000, immutable" },
        { key: "Surrogate-Control", value: "public, max-age=31536000, immutable" },
      ],
    },
    {
      source: "/media/:path*",
      headers: [
        { key: "Cache-Control", value: "public, max-age=31536000, immutable" },
        { key: "CDN-Cache-Control", value: "public, max-age=31536000, immutable" },
        { key: "Surrogate-Control", value: "public, max-age=31536000, immutable" },
      ],
    },
  ];
}

CDN-Cache-Control and Surrogate-Control matter because Cloudflare and Fastly respect them even when they rewrite standard Cache-Control. For ISR pages, I used s-maxage=60, stale-while-revalidate=86400 so the edge serves cached pages instantly while revalidating in the background.

TTFB dropped from 1.7 s to roughly 0.3–0.5 s on cache hits.

LCP image and font hygiene

On pages without a video, the LCP element became the first article image. I stopped lazy-loading it, added fetchpriority="high" and loading="eager", and capped article images at 1024 px wide. Image quality dropped from q-90 to q-70, then to q-60, shrinking payloads by 25–35%.

A follow-up PageSpeed Insights run showed a 730 ms element render delay on the <h1>. The subparts were clear: TTFB was 0 ms, so the delay was almost entirely render-blocking CSS. I extracted ~170 lines of non-critical CSS into a deferred stylesheet, removed unused dark-mode variants, reduced Inter from five weights to one, then removed Inter entirely and fell back to the system font stack. I also inlined critical above-the-fold styles in layout.tsx so the headline could paint correctly even while external CSS was still loading.

Third-party scripts and INP

The biggest single offender after JW Player was Twitter embeds. Loading widgets.js cost 816 ms of main-thread execution, and it ran even when the tweet was below the fold. I built click-to-load facades for Twitter, Instagram, and TikTok embeds. The server-side sanitizer neutralizes iframes by moving src to data-src; a client component renders placeholders and loads the SDK only on interaction.

// SocialEmbedFacades/index.tsx (simplified)
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      const sdk = entry.target.getAttribute("data-sdk");
      if (sdk) loadSdk(sdk);
      observer.unobserve(entry.target);
    }
  });
}, { rootMargin: "300px" });

I added an auto-activation fallback: after the first user interaction, remaining facades load one second later via requestIdleCallback. GTM was deferred behind load + 3 s + requestIdleCallback. Chartbeat was triple-gated: lazyOnload, window.load, then requestIdleCallback.

For INP, I wrapped the category scroll handler in requestAnimationFrame with { passive: true }, cached offsetWidth in carousels with useRef, and debounced the resize hook. I split first-party JS chunks with splitChunks at 100 KB and added @sentry/nextjs to optimizePackageImports.

Results

| Metric | Before | After | | --- | --- | --- | | LCP | 4.5 s | ~2.0–2.5 s | | TTFB | 1.7 s | ~0.3–0.5 s | | Lighthouse | 79 | ~90–95 |

Next time I would start with the architecture audit rather than the Lighthouse checklist. Most wins came from rendering strategy and caching. And the first thing I would check is the LCP element itself: a video player should never own the initial paint.

Takeaways

  • If your LCP element is a video player, defer it with a facade. It costs almost nothing and removes a massive third-party load from the critical path.
  • Make your text LCP element server-rendered. Hydrated headlines pay a tax you do not owe.
  • Align every cache layer. Next.js, unstable_cache, and the CDN should share the same TTL and cache keys.
  • Use CDN-Cache-Control and Surrogate-Control when self-hosting behind Cloudflare or Fastly; standard Cache-Control is not always enough.
  • Third-party scripts are the fastest way to ruin a performance budget. Treat them as guilty until proven innocent.
  • INP is won by removing forced reflow and breaking long tasks, not by adding libraries.
  • Measure the LCP subparts. A 0 ms TTFB with a 730 ms render delay tells you exactly where to look.

Dalintis:

XLinkedIn
Miracle Kalu

Autorius

Miracle Kalu

Senior Full Stack Engineer

Patiko tai, ką perskaitėte?

Svarstau vyresniojo inžinieriaus pozicijas ir techninio konsultavimo projektus. Susisiekime.

Susisiekti →

Paskelbta 2026 m. birželio 13 d. · Skaitymo trukmė – 6 min.

Skaitykite toliau