DEV Community

Gabriel Bachmann
Gabriel Bachmann

Posted on

We cut our Next.js LCP from 1.8s to 0.3s, and almost none of it was 'frontend'

 Our site felt slow. Not broken, just that little bit of lag on every click that makes a product feel cheap. We run GitGem.org, a place to discover open source worth starring, and I finally sat down to fix it.

Here is the result, measured in real Chrome on the homepage:

Metric Before After
TTFB 1.66s 0.18s
First Contentful Paint 1.78s 0.31s
Largest Contentful Paint 1.80s 0.31s
CLS ~0.00 ~0.00

LCP went down about 6x. The interesting part: I barely touched any frontend code. No component refactor, no image lib, no fancy hydration trick. Here is what actually moved the needle, and the one gotcha that cost me a deploy.

Step 1: measure, do not guess

The temptation is to start "optimizing", reach for a bundle analyzer, lazy-load some components, feel productive. Resist it. Spend the first hour measuring so you know what is actually slow.

Two tools, that is it:

  • curl for time to first byte, payload size, and cache headers.
  • A real browser's Performance API for Core Web Vitals.

The very first curl told me almost everything:

$ curl -s -D - -o /dev/null https://gitgem.org/ | grep -i cache-control
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
Enter fullscreen mode Exit fullscreen mode

no-store. Every single visit was recomputing the whole page on the server: database round trips plus a full render, on every request, for everyone. That is the 1.6s TTFB right there.

Then the browser numbers showed the second half of the story:

TTFB 1660ms, FCP 1780ms, LCP 1800ms
Enter fullscreen mode Exit fullscreen mode

FCP equals LCP, and both sit just a hair after TTFB. In other words, the page paints the instant the HTML arrives. There is no render-blocking JavaScript problem, no slow hydration, no layout thrash. LCP was about 90% TTFB. The whole "slowness" was the server taking 1.6 seconds to send the first byte.

That reframes everything. The fix is not in the components. It is in caching.

The stack

GitGem.org is Next.js 16 (App Router) on Cloudflare via OpenNext, with Supabase (Postgres) for data. The feed, topic, and detail pages were all Server Components fetching public data and rendering on every request.

The catch: every route was declared like this.

export const dynamic = 'force-dynamic';
Enter fullscreen mode Exit fullscreen mode

That forces per-request server rendering and the no-store header. It was added with a comment that said, paraphrasing, "there is no incremental cache configured, so this is the reliable way to stay fresh." Reasonable at the time. But this data only changes when a scraper runs every six hours. There is no reason to recompute it for every visitor.

Fix 1: cache the HTML at the edge (ISR)

The pages are 100% public. Nothing per-user is in the server HTML, voting and auth are client components hydrated after load. That means the rendered HTML is identical for everyone, which means it is safe to cache.

So I switched the routes from force-dynamic to ISR (Incremental Static Regeneration):

export const revalidate = 300;        // regenerate at most every 5 min
export const fetchCache = 'force-cache';
Enter fullscreen mode Exit fullscreen mode

fetchCache: 'force-cache' matters because in Next 15+, an uncached fetch (which is what the Supabase client does) forces the route dynamic even if you set revalidate. Forcing the fetches to be cacheable lets the route actually become ISR-eligible.

On Cloudflare, OpenNext needs somewhere to store the rendered pages. That is an R2 bucket plus a small Durable Object queue for revalidation, wired up in open-next.config.ts:

import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
import { withRegionalCache } from "@opennextjs/cloudflare/overrides/incremental-cache/regional-cache";
import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue";

export default defineCloudflareConfig({
  incrementalCache: withRegionalCache(r2IncrementalCache, { mode: "long-lived" }),
  queue: doQueue,
  enableCacheInterception: true,
});
Enter fullscreen mode Exit fullscreen mode

The regional cache keeps a copy in Cloudflare's Cache API per location, so most hits never even touch R2.

I deployed. The homepage went from 1.1s to 0.2s and returned x-nextjs-cache: HIT. Victory.

Except the homepage was the only page that cached. Every feed and detail route was still no-store.

The gotcha that cost me a deploy

Here is the lesson worth the price of admission.

The homepage (app/page.tsx) is a static route, so Next caches it by default once you add revalidate. But my feeds and detail pages are dynamic routes (app/[sort]/[[...filters]]/page.tsx, app/[forge]/[owner]/[repo]/page.tsx). And in the App Router:

A dynamic route is rendered per-request unless it exports a generateStaticParams array, even an empty one. revalidate alone is not enough.

That is straight from the Next docs, and it is easy to miss. The fix is one function per dynamic route:

// Opt a dynamic route into ISR: generate every path on demand, then cache it.
export function generateStaticParams() {
  return [];
}
Enter fullscreen mode Exit fullscreen mode

Returning [] means "prerender nothing at build, but treat this route as static, generate each path on first request, then cache it." Add that, and the dynamic routes finally cached too. TTFB across every feed dropped to ~0.2s.

If you take one thing from this post: if you set revalidate on a dynamic route and it still shows no-store, you are missing generateStaticParams.

Fix 2: stop shipping data you do not render

With TTFB solved, I looked at payload. The feed HTML decompressed to 1.14 MB. Not the transfer size (brotli got that to ~120 KB), but the DOM and inlined data the browser has to parse.

Two cheap cuts:

  1. The query fetched 500 full rows to display 25 ("load more" reveals the rest client-side). I capped it at 150. Still more than anyone scrolls.
  2. The query was select('*'), about 50 columns per row, when the cards render about 20. I replaced it with an explicit column list.
// before
.select('*')
// after: only what the cards actually render
.select('id, forge, owner_name, repo_name, description, language, stars, ...')
Enter fullscreen mode Exit fullscreen mode

Audit every consumer first so you do not ship undefined into the UI. The two together took the feed from 1.14 MB to ~320 KB, a 72% cut.

Results

Every public route went from a 1 to 1.7s server render to a ~0.2s edge hit. LCP from 1.8s to ~0.3s. CLS stayed at zero. The total code change was a handful of export const lines, one config file, one generateStaticParams, and a tighter query.

Takeaways

  • Measure before you optimize. One curl of the cache header saved me from "fixing" things that were already fine.
  • LCP is often just TTFB. If FCP and LCP land right after TTFB, your problem is server response time, not the frontend. Cache the response and both metrics fall together.
  • Public, slow-changing data should be cached, not recomputed. ISR plus a stale-while-revalidate window gives you near-static speed with acceptable freshness.
  • On the App Router, dynamic routes need generateStaticParams to cache. revalidate alone leaves them dynamic.
  • Do not ship data the page never renders. select('*') is a payload tax.

None of this was glamorous. It was mostly deleting force-dynamic and adding the right cache config. But that is usually where the big wins live: not in clever code, but in not doing work you did not need to do.

If you want to see the result, it is live at GitGem.org. Happy to answer questions in the comments.

Gabriel Bachmann

Top comments (2)

Collapse
 
twostroke_null profile image
Marcus Holmberg • Edited

WOW!

Great article and im so glad to be part of the team,
I LOVE IT!

Collapse
 
gitgem profile image
Gabriel Bachmann

Thanks, for kicking the whole project off!