De la 75 la 99: cum am vânat un LCP de 71 de secunde (și am dus Accessibility de la 85 la 96)
Performance 75 → 99 și Accessibility 85 → 96 pe un marketplace Next.js 16. LCP-ul de 71 de secunde, capcanele care m-au costat ore (build vechi rulat în paralel, contenția CPU) și de ce un singur `<img>` neoptimizat a făcut toată diferența.

O sesiune reală de Lighthouse optimization pe un marketplace Next.js 16 — cu debugging adevărat, 3 capcane care m-au costat timp și de ce un singur <img> a făcut toată diferența.
Contextul
Rescriam un marketplace de anunțuri auto din CakePHP în Next.js 16 (App Router, MongoDB, imagini în public/). Funcțional mergea tot. SEO și Best Practices — 100. Dar Performance stătea sub 90 și Accessibility la 85, și nu voiam să livrez așa.
Obiectiv: 90+ la Performance și 95+ la Accessibility pe producție — nu doar „merge pe local".
Partea I — Performance: vânătoarea de LCP
Capcana #1: nu măsura niciodată performanța în dev
Prima greșeală pe care o face toată lumea: rulezi Lighthouse pe next dev și te sperii.
Dev mode în Next e intenționat neoptimizat: fără minificare, React în mod development (dublu-render, warning-uri), Turbopack overhead, fără cache headers. Performance pe dev e mereu 60–70, indiferent cât de bun e codul.
Regula: măsori pe un build de producție (next build && next start), cel mai apropiat proxy de remote.
pnpm build && PORT=3100 pnpm start
npx lighthouse http://localhost:3100/search/cars --only-categories=performance
Rezultat pe build de producție: Performance 75. Tot sub 90. Deci nu era doar dev — era o problemă reală.
Metrici contradictorii = miros de artefact
M-am uitat la breakdown:
| Metrică | Valoare | Scor |
|---|---|---|
| First Contentful Paint | 1.0 s | 🟢 100 |
| Largest Contentful Paint | 64.4 s | 🔴 0 |
| Total Blocking Time | 90 ms | 🟢 99 |
| Cumulative Layout Shift | 0 | 🟢 100 |
| Speed Index | 2.1 s | 🟢 99 |
Stai puțin. Speed Index 2.1s — pagina s-a umplut vizual în 2 secunde. Dar LCP zice 64 de secunde? Iar elementul LCP era un <h3> (text, nu imagine).
Când metricile se contrazic așa (FCP/SI excelente, LCP absurd), nu e cod „lent" — e ceva care blochează stabilizarea paginii. O resursă care se încarcă o veșnicie.
Vinovatul: <img> brut care servea PNG-uri de 2.5MB
curl -s http://localhost:3100/search/cars | grep -oc '/_next/image'
# 0
Zero /_next/image. Imaginile nu treceau prin optimizatorul Next. Se serveau direct:
<img src="/uploads/galleries/3317f641....png" />
Cât cântăreau originalele?
ls -la public/uploads/galleries/*.png # ~2.5 MB / imagine
Cardul de anunț folosea <img> brut, nu next/image. Catalogul are ~12 carduri × 2.5MB = ~30MB de imagini neoptimizate. Sub throttling-ul CPU 4× al Lighthouse, încărcarea lor dura zeci de secunde → LCP exploda la 64s, în timp ce textul (FCP) apărea instant.
// înainte
<img src={listing.image} className="h-full w-full object-cover" />
// după
<Image src={listing.image} alt={listing.title} fill
sizes="(max-width:768px) 100vw, 25vw" className="object-cover" />
Am convertit cardurile, galeria de pe pagina-mașină și hero-ul de pe homepage (alt PNG de 1.6MB). SVG-urile decorative le-am lăsat <img> — sunt vectoriale, next/image nu le optimizează oricum.
Capcana #2: măsuram build-ul VECHI tot timpul
Rebuild, restart, re-măsor:
PERFORMANCE: 71
LCP: 71.5 s
/_next/image: 0
Tot 71 de secunde. Și /_next/image tot 0. Sursa avea clar <Image>, dar HTML-ul randa în continuare <img>-ul vechi, cu className-ul vechi. Nu se lega.
lsof -ti:3100 # 72924 ← proces de dinainte de fix
tail -3 /tmp/prod.log
# port: 3100 is already in use
# Command failed with exit code 1
Iată. Serverul de la prima măsurătoare (înainte de fix) nu se oprise. pnpm start eșua silențios („port in use"), iar curl lovea în continuare build-ul vechi. Toate măsurătorile „post-fix" erau pe codul vechi.
Lecție: când restartezi un server pentru benchmark, verifică că ăla vechi chiar a murit. Un
pnpm startcare eșuează nu te oprește — te lasă să benchmark-uiești fantome ore în șir.
lsof -ti:3100 | xargs kill -9
pkill -9 -f "next-server"
Restart curat:
curl -s http://localhost:3100/search/cars | grep -oc '/_next/image' # 132 ← ACUM da
<img loading="lazy" decoding="async" src="/_next/image?url=...&w=640&q=75" />
Rezultatul
| Metrică | Înainte (<img> brut) | După (next/image) |
|---|---|---|
| Performance | 71 | 88 |
| LCP | 71.5 s | 3.9 s |
| FCP | 0.9 s | 0.9 s |
| CLS | 0 | 0 |
Un singur fix — <img> → next/image — a tăiat LCP-ul de la 71s la 3.9s.
Ultimul kilometru: LCP-ul era lazy
Imaginea LCP (primul card) era loading="lazy" — browserul o amâna intenționat. Soluția: priority pe imaginile above-the-fold ca să le preîncarce.
{listings.map((l, i) => <ListingCard key={l.id} listing={l} priority={i < 4} />)}
Capcana #3: scorul local dansează — e contenția CPU, nu codul
Scorul sărea între 70 și 88 pe același cod. Indiciul: TBT oscila între 60ms și 470ms. TBT (cât stă blocat main thread-ul) e pur CPU-bound — când oscilează pe cod identic, e mașina sufocată (dev + prod + build concurând, peste throttling-ul 4× Lighthouse), nu codul.
Și pe remote? 99.
Pe Vercel, motivele care trag local dispar: image optimization la edge (cache-uit, nu sharp on-demand la fiecare cerere), zero contenție CPU, CDN + HTTP/2 + Brotli. Predicția mea era 90+. Realitatea: Performance 99.
Partea II — Accessibility: 85 → 96
Lighthouse semnala 5 categorii. Patru au fost directe; una a cerut gândire.
Form labels, link names, heading order (ușoare)
- Input-uri fără label (slidere year/price, max-km):
aria-labeldescriptiv. - Link-uri icon fără nume (Sign in cu text
hidden sm:inline→ pe mobil rămâne doar iconița):aria-labelpe link. - Heading order rupt (search:
h1→ directh3la carduri): un<h2 className="sr-only">pentru secțiunea de rezultate.
Capcana de contrast: un singur roșu nu poate fi și bun pe buton și ca text
Aici a fost partea interesantă. Brand-ul folosea #d85151 pentru tot: fundal de buton (text alb pe el) și text (preț, link-uri) pe fundal închis.
Problema — niciunul nu trecea AA (4.5:1):
alb pe #d85151: 4.03 (buton — fail)
#d85151 pe card închis: 3.99 (text preț — fail)
Și nu le poți rezolva pe ambele cu o singură culoare: ca să treacă albul pe roșu ai nevoie de un roșu închis (#b83232); ca să treacă roșul ca text pe închis ai nevoie de un roșu deschis (#e06d6d). Sunt direcții opuse.
Soluția — token split:
--color-primary: #e06d6d; /* TEXT pe dark — luminat, trece AA (5.0:1) */
--color-brand: #b83232; /* FUNDAL de buton — închis, alb trece AA (5.9:1) */
Apoi un sed chirurgical: cele 21 de bg-primary (butoane) → bg-brand, păstrând cele 52 de text-primary pe noul roșu deschis. Badge-ul „Sold" la fel — --color-danger închis la #c62828.
Touch targets (limitare cunoscută)
Singurul audit rămas: thumb-urile slider-ului dual-range se suprapun (două <input type="range"> cu inset:0 peste același track). Le-am mărit la 24px, dar Lighthouse vrea și spațiere între ele — imposibil când prin design ocupă același loc. Pentru 100 ar trebui reproiectat slider-ul; n-am considerat că merită ultimele puncte.
Rezultat: Accessibility 85 → 96.
Scorecard final
| Categorie | Înainte | După |
|---|---|---|
| Performance | 75 | 99 (remote) |
| Accessibility | 85 | 96 |
| Best Practices | 100 | 100 |
| SEO | 100 | 100 |
Ce iei cu tine
- Nu măsura performanța în
dev. Folosește un build de producție. - Metrici contradictorii (FCP bun, LCP absurd) = o resursă care blochează, de obicei o imagine neoptimizată — nu „cod lent".
next/imagenu e opțional. Un<img>brut care servește originalul de 2.5MB e cel mai frecvent killer de LCP.prioritype imaginea above-the-fold.- Verifică ce server măsori. Un
pnpm starteșuat silențios te lasă să benchmark-uiești build-ul vechi ore în șir. - TBT instabil = contenție CPU, nu cod. Liniștește mașina înainte de concluzii.
- Contrastul cere uneori token split: o culoare de brand nu poate fi simultan fundal-de-buton și text-pe-dark. Separă-le.
- Unele audit-uri au limite de design (dual-range vs touch targets). Știi când să te oprești.
Un singur <img> → next/image a făcut diferența între 71 de secunde și sub 4. Restul a fost să nu mă păcălesc singur pe drum.
Asta e exact tipul de muncă pe care îl facem în Audit & fix SEO / AI-SEO — audit real, fix-uri direct în cod. Vezi și optimizare SEO + AEO sau mentenanță continuă pentru monitoring Core Web Vitals.