Published July 29, 2026 · 4 min read
Every number here came from this site. Most of what I tried did nothing; the two things that worked were not the two I expected, and neither of them was about JavaScript.
Every performance number on this page came from this site. I am not going to describe a generic optimization checklist, because the checklist is not the interesting part — the interesting part is that most of what I tried did nothing, and the two things that worked were not the two things I expected.
The starting point was a Lighthouse mobile run with an LCP of 4.8 seconds and a Total Blocking Time of 590ms. That is a bad score for a portfolio site that is mostly text.
Where the time actually went
The first mistake I made was assuming the problem was JavaScript, because that is what everyone assumes. It was not. I measured the execution profile and the app's own code accounted for 34ms. Everything else was framework overhead — React, the Next.js App Router runtime, hydration. There was no clever refactor available to me there, because none of it was mine.
The 590ms of blocking time was render-blocking CSS. Next.js was emitting several stylesheets as separate link tags, and the browser will not paint until it has all of them. On a fast connection this is invisible. On a throttled mobile connection it is most of your Largest Contentful Paint.
What did not work
I tried deferring the stylesheet with an inline script that swapped the media attribute and used a MutationObserver to catch chunks added later. It worked, in the sense that the build reported around 560ms of savings and the stylesheet genuinely loaded asynchronously.
It also did nothing useful, because the page still could not paint its final state until the CSS arrived. I had moved the block rather than removed it. That script is gone now.
What worked
Two things.
The first was getting the stylesheet off the critical path for real. I started by hand-extracting the utilities the first viewport needs into a style tag, which worked and was tedious to keep correct. What the site ships now is one line of config — Next's experimental inlineCss, which emits the stylesheet as a style element in the HTML at render time instead of a link the browser has to fetch before it can paint. It costs about 16 KiB of extra HTML and gives up cross-navigation CSS caching, which is a fair trade for a site most people visit once.
If you take one thing from this note, take that: I spent an afternoon doing by hand what a configuration flag does completely, and the flag is also the version that cannot drift out of date as the markup changes.
LCP went from 4.8s to 2.64s at that point, and Total Blocking Time from 590ms to effectively zero.
The second was auditing what was on the critical path at all, and being ruthless about it. A decorative animated border was pulling in a 110 KiB animation library; I rewrote it as a CSS animation and deleted the dependency. The navigation bar was importing icons from a component library; I replaced them with inline SVG. Neither change is clever. Both were worth more than anything I did to the JavaScript I had written myself.
The part nobody mentions
Some of it cannot be fixed. Next.js hardwires a legacy polyfill module into every build, and Lighthouse flags it as 14 KiB of unnecessary JavaScript on every run. I spent an afternoon trying to strip it with a bundler alias. It cannot be done from userspace. The correct response to that audit item is to accept it.
Third-party scripts are the other half of this. Analytics, tag managers, review widgets and bot detection are not in your bundle and do not show up in your build output, but they run on the main thread of every real user's browser. If your local build is fast and your field data is slow, that gap is where to look — not in your own code.
What I would tell you to do first
Measure before you change anything, and measure the thing users experience rather than the thing your bundler reports. A build that emits less JavaScript is not automatically a page that paints sooner.
Then look at what blocks the first paint. On most Next.js sites that is CSS, not JavaScript, and the fix is unglamorous: work out what the first screen genuinely needs, inline it, and let everything else arrive late.
Core Web Vitals reward a page that shows something correct quickly. They do not reward a small bundle. Those are related but they are not the same goal, and optimizing for the second one is how you end up with a 34ms application that takes five seconds to paint.
Where it landed, and what came back
The remaining gap from 2.64s closed through unglamorous image work rather than anything architectural: marking the one image that is actually the Largest Contentful Paint element as priority instead of letting it lazy-load, and correcting sizes attributes that had been estimated rather than measured, so the browser stops fetching a candidate wider than the slot it renders into. The site currently measures an LCP of 1.6 seconds.
Total Blocking Time did not stay at zero. Adding a consent banner and analytics put roughly 190ms back on the main thread, and that is the honest shape of this work: the number you win is not permanent, because every feature you add afterwards spends some of it. Measuring once and putting the result in a case study is how you end up quoting a figure your site no longer produces.
Next.js optimizing guides: https://nextjs.org/docs/app/building-your-application/optimizing Core Web Vitals: https://web.dev/articles/vitals Largest Contentful Paint: https://web.dev/articles/lcp Total Blocking Time: https://web.dev/articles/tbt
If this is the kind of problem you are dealing with — type safety, forms, or a codebase that keeps surprising you — a short call is the fastest way to find out whether I can help.