I run LoveyDear, a small app for digital wedding invitations and personal surprise pages. Most visitors arrive from an ad on a phone, land on one form page, and either start filling it in or leave. So that one page has to be fast on a mid-range Android over a mediocre connection.
In September 2026 the wedding invitation form page scored 62 on mobile Lighthouse. After a week of measuring instead of guessing, it scored 100. None of the fixes were exotic. Here are the five real bottlenecks, plus the one setting that did more than everything else.
All numbers below come from Lighthouse 12, mobile, with --throttling-method=devtools (real throttling). "Before" is the live site, "after" is a local production build measured the same way. More on why that flag matters at the end.
| Before | After | |
|---|---|---|
| Performance | 62 | 100 |
| FCP | 3.1 s | 1.3 s |
| LCP | 4.5 s | 1.3 s |
| CLS | 0.328 | 0 |
| JS on first load (raw) | 1,239 KB | 737 KB |
1. The LCP element was the cookie banner
The biggest "render delay" in the trace (almost 4 seconds) was not an image. It was the cookie consent banner. It only appeared after hydration, when an effect read localStorage, and because it was the largest text block on the screen, LCP waited for it.
Fix: render the banner on the server, always. A tiny inline script in the root layout runs before first paint, checks whether the visitor already decided, and sets a data- attribute on <html>. One CSS rule hides the banner when that attribute exists. No flash, and LCP dropped to the same value as FCP.
Lesson: look at which element is the LCP before optimising images. It is often something you would never suspect.
2. All of the CLS came from the footer
CLS was 0.328, and every bit of it was the site footer. The form rendered late on the client, and as it appeared it pushed the footer down.
The real fix was the next point, but I also removed the footer from this page entirely. On a single-purpose form page it only competed with the form.
3. The form was missing from the server HTML
The form component had a guard like this:
if (compactInvitation && !draftLoaded) return <div>Preparing your invitation…</div>;
It waited for an IndexedDB read (a saved draft) before rendering anything, so the server sent a placeholder and the real form only appeared after hydration plus the IndexedDB round trip.
I removed the guard. The form is in the first paint now. If a half-finished draft is found, a "continue where you left off" prompt still appears, but not if the user has already started typing, so nothing they typed seems to vanish.
4. Third-party SDKs were on the critical path
Three packages were in the first JS chain for no good reason:
-
Analytics SDK (~250 KB raw): now loaded with a dynamic
import()after theloadevent, when the browser is idle (requestIdleCallback, 2.5 s timeout). Page views fired before that are queued and sent in order. -
@supabase/supabase-js(~230 KB raw): a small cart bar in the root layout imported the client at module top level, so every page paid for it even with an empty cart. A lazygetSupabase()helper now imports it on first real use. -
qrcode(~94 KB raw): only needed on the success screen after submitting. That screen is now anext/dynamiccomponent withssr: false.
5. Prefetching the home page while the user typed
The page's header linked to the home page, and Next.js prefetched it (about 61 KB including an animation library) exactly when people started typing into the form. Turning prefetch off for the links on this page removed that competition.
The setting that beat everything: inlineCss
After those fixes, FCP was still around 2.4 s. The trace showed why: the CSS file (17 KiB) was downloading in parallel with 132 KiB of fonts and took 1.7 s on a throttled connection. The browser also only discovers the stylesheet <link> after parsing the HTML, which costs another round trip.
Next.js 16 has experimental: { inlineCss: true }, which puts the CSS directly into the HTML. I ran an A/B test, two runs per variant:
| Variant | Score | FCP/LCP | Note |
|---|---|---|---|
| Baseline | 95 | 2,389 ms | — |
Heading font preload: false
|
97 | 2,054 ms | font flash on headings |
Both fonts preload: false
|
98 | 2,000 ms | font flash everywhere |
inlineCss |
100 | 1,330 ms | no visual change |
inlineCss + no font preload |
100 | 1,340 ms | no extra gain, font flash |
Inlining the CSS gained 2.7 times more than any font trick, and it does not change how the page looks. So I left the fonts alone.
It has a real cost. Inlined CSS cannot be cached separately, and Next also carries it in the RSC payload. The raw HTML went from 25 KB to 413 KB (6.2 to 69.5 KiB compressed), and every client-side navigation costs about 40 KiB more. I accepted that because almost every session here is a first visit to a single page. If your users browse many pages per session, measure this trade-off again before you copy it.
Things I measured and did not change
-
React re-renders were not the INP problem. With 4x CPU throttling a keystroke took 24 to 32 ms. Sprinkling
memowould not have moved that. The real input delay happens while the page is still loading, and the fix for that is shipping less JS. -
The fonts were already fine. Both are variable fonts with Latin Extended (Turkish needs
ş,ğandı). Restricting weights would have increased the number of files. - Splitting Tailwind per route saved only 4 KB out of 108 KB in an experiment, not worth the regression risk.
A trap: simulate lies on localhost
Lighthouse's default throttling method, simulate, models the network instead of applying it. On localhost everything finishes in about 250 ms, so the model treats every script as an LCP dependency and reports something like 3.7 s for an LCP that was really 246 ms. Use --throttling-method=devtools for local runs and keep simulate for the live URL.
Two more:
-
Headless Chrome cannot measure INP reliably. When the page is idle it stops producing frames, so event durations balloon to 500 to 800 ms. Use a headed browser or keep a
requestAnimationFrameloop running. -
Don't run
next buildwhilenext startis serving the same folder. The server keeps the old manifest in memory and mixes it with the new files, and your measurements quietly become fiction.
Takeaways
- Find the actual LCP element first.
- Make sure your critical content is in the server HTML.
- Move every SDK you don't need for the first interaction off the critical path.
- Try
inlineCsson landing pages that get mostly first-time visitors, and measure what it costs you. - Write down what you measured and didn't change, so you don't try it again in three months.
If you are curious what the page looks like now, it is the digital wedding invitation form on LoveyDear. Happy to answer questions in the comments.
Top comments (0)