All entries
#engineering#performance#nuxt

The Slow Page Was Not the Images

Search Console reported poor LCP across a news site. The obvious culprit was photo weight. It wasn't, and the cache rule meant to fix it had silently never matched a single request.

Google Search Console flagged poor Largest Contentful Paint across nearly every URL on jatimnet.com, a regional news site in East Java I rebuilt and still maintain. Thousands of pages, all failing.

A news site is mostly photographs, so the obvious conclusion is the images are too heavy and the obvious fix is compression. I measured first. The obvious conclusion was wrong.

Measure before optimizing what you assume

Time to first byte was around 3.2 seconds. Before any image is requested, before the browser has anything to lay out, three seconds are gone. Image weight contributed, but it wasn't the driver. You can't compress your way out of a page that takes three seconds to start.

The cause was upstream. The site is a Nuxt 3 front end rendering server-side from a separate content management system, and rendering a page meant several chained uncached calls to that API. Every visitor paid the full chain.

Writing that down mattered as much as fixing it. "Poor LCP" and "site full of photos" pair so naturally that I could have spent a week on image optimization, shipped a real improvement, still failed the metric, and believed I'd addressed it.

The cache rule that never matched

The fix was straightforward. Cache the rendered pages, serve stale while revalidating in the background. The framework supports exactly this through route rules. I added them, deployed, and nothing moved.

I'd written the rules using the file-based routing syntax, keys like /[slug]. That's how the page files are named, so it looks right in every way that matters to a reader.

But route rules are matched against the request URL by the server's router, which treats [slug] as a literal path segment. It was looking for URLs containing those actual bracket characters. No real request ever matched. Nothing errored, because a rule matching nothing looks exactly like a rule that works until you check.

The correct syntax is the router's own. /:slug for a single segment, /kategori/** for a subtree. I confirmed it by checking for a cache-control header on the response, which is the only way to tell "caching works" from "caching silently does nothing".

Cache-hit TTFB went from roughly 3.2 seconds to about 3 milliseconds.

One wrinkle if you hit this. A hydration-delay module in the same project resolves its config through the application router, which genuinely does understand the [slug] form. So both syntaxes had to coexist in one config file, the same-looking key meaning different things to different consumers. Nasty trap, and the reason I now verify cache behaviour with a request instead of by reading config.

Also worth knowing: this kind of caching doesn't run in the dev server. Testing it means building and running the production output. A comfortable local test proves nothing.

The photos really were upside-down

Separately, hero photos shot on phones rendered rotated.

Phone cameras store the sensor's raw orientation plus an EXIF tag saying which way is up. Our image pipeline strips EXIF during resize, which is reasonable since it's mostly junk, but doesn't physically rotate the pixels first. The orientation instruction gets discarded while the pixels stay as the sensor recorded them, and the photo lands sideways.

The fix wasn't in our pipeline at all. The upstream CMS already generates pre-sized thumbnails with orientation baked into the pixels, so preferring those over the original solved rotation outright. It also solved two other things. The thumbnail is around 150KB instead of a multi-megabyte original, which helps LCP, and because its dimensions are known we could set explicit width and height and mark it high priority, removing the layout shift as it loads.

One change, three metrics, and only because the right fix was "use the asset that's already correct" instead of "add processing to make the wrong asset correct".

A limitation I documented rather than hid: carousel images have no generated thumbnails in the CMS, so they can still rotate. Fixable upstream, not on the client. Pretending otherwise would have produced a confident fix that didn't work.

Four things for the next site

Measure TTFB before touching anything visual. If the server is slow, no front-end work passes the metric, and front-end work is where everyone's instinct goes first.

Verify a cache rule with a request, not by reading the config.

Prefer the asset that's already correct, because processing you don't do can't be subtly wrong.

And write down what you didn't fix. The carousel images still rotate. Someone should know that, including future me.