Knowledge Mark G
Hire Me

How We Fixed Unused JavaScript and LCP Render Delay in Next.js

Each issue in our own PageSpeed report and exactly what we changed - code splitting, the fade-in that hid our headline, analytics on interaction, two fixes that backfired - plus e-commerce speed and security headers.

Kausar RazaBy Published on · 9 min read · 5 views
Share:

Web Development

  • Performance
  • Core Web Vitals
  • Website Speed
  • Security

Need help with this?

I build and fix business websites - fast, mobile-first and built to bring enquiries.

Get in touch

We build fast sites for clients, so a mediocre PageSpeed score on our own home page is not a good look. This is the report we started from on 19 October 2026, each issue in it, and exactly what we changed - including two fixes that sounded right and made things worse. The site is Next.js 16 behind Cloudflare; most of this applies to any React site, and the last sections cover what changes for an online shop and where security headers fit in.

The starting point

Lighthouse 12.8, mobile profile, against the live home page:

MetricResult
Performance score72
First Contentful Paint1.9 s
Largest Contentful Paint4.5 s
Total Blocking Time370 ms
LCP elementthe header logo
Unused JavaScriptan 89 KiB chunk, gtag.js 65 KiB, AdSense 35 KiB
Render-blocking CSS452 ms
DOM size2,239 elements

The LCP breakdown was the giveaway: TTFB 653 ms, resource load delay 822 ms, load time 66 ms, and render delay 2,918 ms. The logo downloaded in a fraction of a second and then waited almost three seconds to be painted. That is not a network problem, and no CDN setting fixes it.

Unused JavaScript: an 89 KiB chunk nobody asked for

The biggest unused file had a hashed name, so the first job was finding out what was in it. Next.js 16 has a bundle analyser built in:

npx next experimental-analyze -o

It writes per-route data to .next/diagnostics/analyze. The chunk turned out to contain the docx library and PDF helpers - code for our PDF-to-Word and Word-to-PDF tools - and the home page was not even loading it directly. Next.js prefetches the code for routes linked from the page, and the header links to several tools. The tool page imported a registry of all 54 tools, so prefetching any one tool pulled in the code for all of them. Every tool page was shipping about 1 MB of JavaScript for the same reason.

The fix was to load each tool's component on its own with next/dynamic, from a small client module:

"use client";
import dynamic from "next/dynamic";

const tools = {
  "word-counter": dynamic(() => import("./impl/text-tools").then((m) => m.WordCounter)),
  "pdf-to-word":  dynamic(() => import("./impl/pdf-tools").then((m) => m.PdfToWord)),
  // ...one line per tool
};

export function ToolComponent({ slug }) {
  const Tool = tools[slug];
  return Tool ? <Tool /> : null;
}

dynamic() still renders the component on the server, so the HTML and its SEO value are unchanged; the browser just stops downloading the other 53. One trap: the server page cannot call a function exported from a "use client" file - the build fails with "Attempted to call hasTool() from the server but hasTool is on the client" - so the list of tool slugs lives in a plain shared module. Result: a tool page went from 1,059 KiB to 767 KiB of JavaScript (321 to 237 KiB gzipped), and the unused chunk disappeared from the home page report.

If you see "reduce unused JavaScript" on a Next.js site, look for exactly this pattern: one file that imports everything, used by a route that only needs one piece of it. Icon libraries are the other usual suspect; experimental.optimizePackageImports: ["lucide-react"] makes sure only the icons you use are bundled.

LCP render delay: the fade-in that hid the headline

Why was the logo the LCP element, not the big headline? Because the hero animated in with a fade starting from opacity: 0. Chrome does not count an invisible element as a Largest Contentful Paint candidate, so while the headline was fading in, the largest visible thing was the logo - and the measurement only settled once the animation had finished. A 620 ms fade with staggered delays was adding seconds of "render delay" on a throttled phone.

We kept the entrance and dropped the fade: the hero now slides up 10 px without changing opacity.

@keyframes rise-up {
  from { transform: translateY(10px); }
  to   { transform: none; }
}
.rise { animation: rise-up 620ms cubic-bezier(0.22, 1, 0.36, 1) both; }

The headline is now a candidate from its first paint. If your LCP element is something small like a logo while a large headline sits right next to it, check for an entrance animation first.

We also sized the logo to what is actually drawn. It was declared as 260×64 but displayed about 44 px tall, so the image optimiser was choosing a 640 px file for phones. Declared at 180×44 with fetchPriority="high", it downloads the right file first. (On this Next.js version priority alone was not adding the fetchpriority attribute - check the rendered HTML rather than trusting the prop.)

The render delay that remains

Not all of it went away, and it is worth explaining why. In a trace of the page loading in headless Chrome, the main thread was idle for about a second before the first paint. That second was spent on GPU rasterisation - drawing the hero's illustration, gradients and shadows. Removing the illustration as a test brought first paint from about 1.65 s to 1.15-1.45 s. PageSpeed Insights also runs in a headless browser with software rendering, so it sees this cost; a real phone with a GPU sees much less of it. Rebuilding the hero to be cheaper to draw is a design change, not a configuration change, and we have not done it yet.

Third-party scripts: wait for the visitor

gtag.js and AdSense were already loading with strategy="lazyOnload", which runs them after the page's load event. That sounds late, but it is still inside the window Lighthouse uses to measure Total Blocking Time, and Google Analytics' start-up ran as 300+ ms of long tasks there.

Both now load on the visitor's first interaction - a scroll, tap, key press or mouse movement. Analytics also has a fallback: four seconds after the page has loaded, once the browser is idle, so a visitor who reads without touching anything is still counted.

const EVENTS = ["pointerdown", "keydown", "touchstart", "scroll", "mousemove"];

function afterInteraction(start, idleAfterLoadMs) {
  let done = false;
  const run = () => {
    if (done) return;
    done = true;
    EVENTS.forEach((e) => removeEventListener(e, run));
    start();
  };
  EVENTS.forEach((e) => addEventListener(e, run, { once: true, passive: true }));
  if (idleAfterLoadMs !== undefined)
    addEventListener("load", () =>
      setTimeout(() => requestIdleCallback(run, { timeout: 2000 }), idleAfterLoadMs));
}

afterInteraction(() => {
  window.dataLayer = window.dataLayer || [];
  window.gtag = function () { dataLayer.push(arguments); };
  gtag("js", new Date());
  gtag("config", "G-XXXXXXXXXX");
  const s = document.createElement("script");
  s.async = true;
  s.src = "https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX";
  document.head.appendChild(s);
}, 4000);

Be honest about the trade-offs. A visitor who leaves within about four seconds without interacting is not counted. Ads only load once someone interacts, which costs nothing in practice because our ad slots are below the fold. And we send Core Web Vitals to GA, which report TTFB and FCP before GA has loaded - those are now queued and sent once GA is configured, instead of being silently dropped.

Two fixes that made things worse

Inlining the CSS. Lighthouse flagged the stylesheet as render-blocking (452 ms), and Next.js has experimental.inlineCss to put it in the HTML. On this site the stylesheet is 277 KB uncompressed, because one Tailwind file covers the blog, 54 tools and the admin area - and inlining it put the CSS into the page twice, once as a <style> tag and again in the React Server Components payload. The home page HTML went from 520 KB to 1.38 MB and First Contentful Paint got worse. Reverted. Inlining only makes sense when the CSS is small.

A modern browserslist. Lighthouse reported 13 KiB of legacy JavaScript (polyfills for features every current browser has). Setting browserslist to Chrome 111, Edge 111, Firefox 111 and Safari 16.4 is the textbook fix. The estimate did not move: those polyfills ship inside the framework's own pre-built code, which your config does not recompile. It does no harm, but it did not fix the warning either.

Smaller fixes

  • Image sizes. Portfolio cards are about 400 px wide in a three-column grid but declared 33vw, so they were served from a 640 px file. They now declare (max-width: 768px) 100vw, (max-width: 1024px) 50vw, 420px, and we added 480 to the optimiser's imageSizes so there is a file between 384 and 640.
  • DOM size. 2,239 elements is mostly the home page being long. We wrapped every below-the-fold section in content-visibility: auto with a contain-intrinsic-size, so the browser skips laying them out until they approach the viewport. That does not change the element count Lighthouse reports - it changes how much of it the browser has to process up front.
  • Cloudflare. Email Address Obfuscation injects its own script; we are switching it off. The full list of Cloudflare settings we use is in Cloudflare speed settings and our Brotli vs zstd vs gzip benchmark.

The result

To compare like with like, both versions were built and tested on the same machine with the same Lighthouse mobile settings:

MetricBefore (2 runs)After (3 runs)
Performance score60-6266-73
Total Blocking Time350-400 ms210-340 ms
Largest Contentful Paint7.4-7.9 s5.6-5.7 s
JavaScript on a tool page1,059 KiB767 KiB

Local numbers are worse than the live site's in absolute terms because the local server fetches its data from a remote API; it is the difference between the columns that counts. With so few runs, treat the ranges as ranges. Lab scores also move from run to run - the field data in Search Console's Core Web Vitals report, from real visitors over 28 days, is what Google ranks on. Our Core Web Vitals guide explains how to read the two side by side.

What changes for an e-commerce site

Everything above applies to a shop, with higher stakes: a slow product page loses the sale, not just the reader. The places shops usually lose time are predictable:

  • The main product image is the LCP element. Never lazy-load it, give it fetchpriority="high", and serve it in AVIF or WebP at the size it is displayed. Lazy-load the gallery thumbnails below it instead.
  • Third-party widgets pile up. Review widgets, live chat, marketing pixels, heatmaps and recommendation engines each bring their own JavaScript. Load anything not needed for the first view on interaction, as above - a chat widget nobody has opened does not need to block the page.
  • Theme and plugin JavaScript. On Shopify and WooCommerce, "reduce unused JavaScript" usually points at apps and plugins loading their scripts on every page, including ones that do not use them. Remove what you no longer use; it is the most effective fix.
  • TTFB on category and search pages. Faceted filters and sorting often make these the slowest pages on the site and the hardest to cache. Test them, not just the home page - our website speed test can run across every page in your sitemap and list the slowest.
  • Caching. Cache product and category HTML at the edge, but never cart, checkout or account pages. The Cloudflare rules for that are in the post linked above.
  • Do not defer payment scripts. Whatever you delay, leave the checkout's payment provider alone.

Security headers: free, and no speed cost

While auditing performance it is worth checking security headers, because they cost nothing in speed: they are a few hundred bytes on each response. Our free security headers checker fetches a page and grades each header with what to add. This site sends:

Strict-Transport-Security: max-age=31536000
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), usb=(), payment=(), browsing-topics=()
Content-Security-Policy: frame-ancestors 'self'; base-uri 'self'; object-src 'none';
                         form-action 'self'; upgrade-insecure-requests

Two deliberate gaps. HSTS has no includeSubDomains until every subdomain is confirmed to work over HTTPS, because a browser that has seen it will refuse plain HTTP on all of them for a year. And the Content-Security-Policy has no script-src: a strict script policy in Next.js needs a fresh nonce on every request, which makes every page dynamic and throws away the edge caching our TTFB depends on. That is a real trade-off between security and speed, and for a site with no logins or payments we chose speed. A shop taking payments should weigh it the other way, and a payment page in particular deserves a strict policy.

Frequently asked questions

How do I reduce unused JavaScript in Next.js?+
Find what is in the unused chunk first - npx next experimental-analyze writes a per-route breakdown. The usual cause is one module importing many components that a route only uses one of; load those with next/dynamic. Also use optimizePackageImports for icon libraries, and load analytics and ads after the first interaction.
What causes LCP render delay?+
The LCP resource has arrived but is not painted yet. Common causes are an entrance animation starting at opacity 0 (Chrome does not count invisible elements), render-blocking CSS or JavaScript, long main-thread tasks, and an expensive first frame to rasterise.
Why is my logo the LCP element?+
Often because the larger element - usually the headline - is invisible when the page first paints, for example fading in from opacity 0. Make the entrance animation move without fading and the headline becomes the LCP candidate from its first paint.
Does loading Google Analytics on interaction lose data?+
A little. Visitors who leave within a few seconds without scrolling, tapping or moving the mouse are not counted unless you add an idle-time fallback. We load GA on first interaction or four seconds after load, whichever comes first, and queue Web Vitals so they are not dropped.
Should I use experimental.inlineCss in Next.js?+
Only if your CSS is small. With a 277 KB stylesheet it put the CSS into the HTML twice (a style tag and the RSC payload), the home page HTML grew from 520 KB to 1.38 MB, and First Contentful Paint got worse.
How do I speed up an e-commerce website?+
Load the main product image first in AVIF or WebP at its displayed size, remove unused apps and plugins, delay chat, review and marketing widgets until interaction, test category and search pages for slow TTFB, and cache product pages at the edge while never caching cart, checkout or account pages.
Do security headers slow down a website?+
No - they are a few hundred bytes per response. The only speed trade-off is a strict Content-Security-Policy with per-request nonces, which stops pages being statically cached. The other headers are free.

Join the conversation

No comments yet — be the first to share what you think.

Leave a comment

Never published.

Optional.

Keep reading

Related articles

View all