[{"data":1,"prerenderedAt":1017},["ShallowReactive",2],{"lastUpdated":3,"blog-de-69-a-97-no-lighthouse-en":4},"2026-09-08",{"post":5,"displayLang":1015,"isFallback":1016,"hasOtherLang":1003},{"id":6,"title":7,"body":8,"category":76,"date":1000,"description":1001,"extension":1002,"featured":1003,"meta":1004,"navigation":1003,"path":1005,"seo":1006,"stem":1007,"tags":1008,"__hash__":1014},"blog_en/en/blog/de-69-a-97-no-lighthouse.md","From 69 to 97 on web vitals: What could be improved in the PN Imóveis showcase",{"type":9,"value":10,"toc":983},"minimark",[11,16,37,44,47,218,221,226,237,249,251,259,275,281,457,460,462,466,485,492,502,505,507,511,517,539,542,547,584,586,590,593,604,617,628,630,634,637,698,700,704,710,717,724,743,841,845,890,892,896,899,930,932,936,965,967,971,979],[12,13,15],"h1",{"id":14},"from-69-to-97-on-core-web-vitals-what-could-be-improved-in-the-pn-imóveis-showcase","From 69 to 97 on Core Web Vitals: What could be improved in the PN Imóveis showcase",[17,18,19,20,27,28,32,33,36],"p",{},"The Performance score for the ",[21,22,26],"a",{"href":23,"rel":24},"https://pnimoveis.com.br",[25],"nofollow","pnimoveis.com.br"," homepage on Lighthouse went from ",[29,30,31],"strong",{},"69 to 97",". LCP — the time until the largest visible element renders — dropped from ",[29,34,35],{},"6.8s to 2.0s"," on a mid-tier mobile device over a slow 4G network. Accessibility rose from 84 to 96, SEO went from 83 to 100, and WhatsApp link previews started working properly with rich thumbnails and prices.",[17,38,39],{},[40,41],"img",{"alt":42,"src":43},"Lighthouse Score: From 69 to 97","/blog/de-69-a-97-no-lighthouse/image.png",[17,45,46],{},"This post documents the journey: what we uncovered, why each bottleneck hurt, what we refactored, and how we solved each performance issue.",[48,49,50,66],"table",{},[51,52,53],"thead",{},[54,55,56,60,63],"tr",{},[57,58,59],"th",{},"Metric",[57,61,62],{},"Before",[57,64,65],{},"After (Production)",[67,68,69,85,100,114,128,143,158,173,188,203],"tbody",{},[54,70,71,77,80],{},[72,73,74],"td",{},[29,75,76],{},"Performance",[72,78,79],{},"69",[72,81,82],{},[29,83,84],{},"97",[54,86,87,92,95],{},[72,88,89],{},[29,90,91],{},"Accessibility",[72,93,94],{},"84",[72,96,97],{},[29,98,99],{},"96",[54,101,102,107,109],{},[72,103,104],{},[29,105,106],{},"Best Practices",[72,108,99],{},[72,110,111],{},[29,112,113],{},"100",[54,115,116,121,124],{},[72,117,118],{},[29,119,120],{},"SEO",[72,122,123],{},"83",[72,125,126],{},[29,127,113],{},[54,129,130,135,138],{},[72,131,132],{},[29,133,134],{},"First Contentful Paint",[72,136,137],{},"2.5 s",[72,139,140],{},[29,141,142],{},"1.2 s",[54,144,145,150,153],{},[72,146,147],{},[29,148,149],{},"Largest Contentful Paint",[72,151,152],{},"6.8 s",[72,154,155],{},[29,156,157],{},"2.0 s",[54,159,160,165,168],{},[72,161,162],{},[29,163,164],{},"Speed Index",[72,166,167],{},"4.1 s",[72,169,170],{},[29,171,172],{},"2.9 s",[54,174,175,180,183],{},[72,176,177],{},[29,178,179],{},"Cumulative Layout Shift",[72,181,182],{},"0.004",[72,184,185],{},[29,186,187],{},"0",[54,189,190,195,198],{},[72,191,192],{},[29,193,194],{},"Total Blocking Time",[72,196,197],{},"170 ms",[72,199,200],{},[29,201,202],{},"130 ms",[54,204,205,210,213],{},[72,206,207],{},[29,208,209],{},"Transferred Bytes (Homepage)",[72,211,212],{},"2.8 MB",[72,214,215],{},[29,216,217],{},"~0.6 MB",[219,220],"hr",{},[222,223,225],"h2",{"id":224},"the-application","The Application",[17,227,228,229,232,233,236],{},"PN Imóveis is the public real estate portal for an agency based in Pernambuco, Brazil. It includes a homepage with featured properties, a search interface with filters, individual property pages, and an administrative dashboard for brokers. Stack: ",[29,230,231],{},"Nuxt 4"," (Vue) with ",[29,234,235],{},"PrimeVue",", API running in the same Nitro process, self-hosted PostgreSQL via Supabase on a VPS managed by Coolify. Property photos are hosted on Supabase Storage behind a Cloudflare CDN.",[17,238,239,240,243,244,248],{},"An early architectural decision played a major role: the entire application was built as a Single Page Application (",[29,241,242],{},"SPA",") with ",[245,246,247],"code",{},"ssr: false",". The server served a blank HTML shell, and the client browser rendered everything via JavaScript. While suitable for an admin panel, it proved problematic for a public showcase.",[219,250],{},[222,252,254,255,258],{"id":253},"the-audit-report-was-generic-curl-revealed-the-truth","The Audit Report Was Generic. ",[245,256,257],{},"curl"," Revealed the Truth.",[17,260,261,262,266,267,270,271,274],{},"We started with a Lighthouse: Performance 69, LCP 6.8s, and generic audit recommendations like ",[263,264,265],"em",{},"\"Use efficient cache lifetimes (1,352 KiB)\"",", ",[263,268,269],{},"\"Reduce unused JavaScript (692 KiB)\"",", and ",[263,272,273],{},"\"Improve image delivery (1,190 KiB)\"",".",[17,276,277,278,280],{},"To get actionable details, we fetched the raw page using ",[245,279,257],{}," and inspected the response payload.",[48,282,283,299],{},[51,284,285],{},[54,286,287,290,293,296],{},[57,288,289],{},"Resource",[57,291,292],{},"Size",[57,294,295],{},"Cache",[57,297,298],{},"Finding",[67,300,301,319,338,359,378,400,420,438],{},[54,302,303,308,313,316],{},[72,304,305],{},[29,306,307],{},"Homepage HTML",[72,309,310],{},[29,311,312],{},"501 KB",[72,314,315],{},"None",[72,317,318],{},"An SPA shell should be ~10 KB. It contained 462 KB of embedded inline CSS.",[54,320,321,326,332,335],{},[72,322,323],{},[29,324,325],{},"Entry JS Bundle",[72,327,328,331],{},[29,329,330],{},"504 KB"," (146 KB gzipped)",[72,333,334],{},"1 year",[72,336,337],{},"Globally registered all ~90 PrimeVue components.",[54,339,340,345,351,353],{},[72,341,342],{},[29,343,344],{},"Hero Image",[72,346,347,350],{},[29,348,349],{},"359 KB"," (1920px JPEG)",[72,352,315],{},[72,354,355,356,274],{},"It was the LCP element, hidden inside CSS ",[245,357,358],{},"background-image",[54,360,361,366,372,375],{},[72,362,363],{},[29,364,365],{},"Property Card Image",[72,367,368,371],{},[29,369,370],{},"500–565 KB"," (2000px WebP)",[72,373,374],{},"4 hours",[72,376,377],{},"Rendered in a small 400px card container.",[54,379,380,385,391,393],{},[72,381,382],{},[29,383,384],{},"Fonts & Logos",[72,386,387,390],{},[29,388,389],{},"17–85 KB"," each",[72,392,315],{},[72,394,395,396,399],{},"Served without any ",[245,397,398],{},"Cache-Control"," header.",[54,401,402,409,414,417],{},[72,403,404],{},[29,405,406],{},[245,407,408],{},"/robots.txt",[72,410,411],{},[29,412,413],{},"67 KB",[72,415,416],{},"—",[72,418,419],{},"Returned the full SPA HTML page to web crawlers.",[54,421,422,427,433,435],{},[72,423,424],{},[29,425,426],{},"Icon Sprite",[72,428,429,432],{},[29,430,431],{},"238 KB"," SVG",[72,434,416],{},[72,436,437],{},"Inlined into the DOM on every public page inside the JS bundle.",[54,439,440,447,452,454],{},[72,441,442],{},[29,443,444],{},[245,445,446],{},"\u003Clink rel=\"prefetch\">",[72,448,449],{},[29,450,451],{},"158 links",[72,453,416],{},[72,455,456],{},"Prefetched every build chunk, competing for bandwidth with critical assets.",[17,458,459],{},"Each item translated a generic Lighthouse warning into a clear engineering target. The first row alone explained a major portion of the initial load delay.",[219,461],{},[222,463,465],{"id":464},"issue-1-half-a-megabyte-of-css-injected-into-empty-html","Issue 1: Half a Megabyte of CSS Injected into Empty HTML",[17,467,468,469,472,473,476,477,480,481,484],{},"The PrimeVue Nuxt module had a default setting that pre-injected component theme CSS into the ",[245,470,471],{},"\u003Chead>",". Without an explicit list of used components, it injected styles for ",[29,474,475],{},"every component"," — autocomplete, cascadeselect, treetable, terminal, dock, org chart, etc. This resulted in 184 ",[245,478,479],{},"\u003Cstyle>"," blocks totaling 462 KB on ",[29,482,483],{},"every"," HTML response for an SPA shell that rendered no initial DOM content.",[17,486,487,488,491],{},"This payload was uncacheable (being raw HTML) and had to be parsed before any rendering could take place. Lighthouse categorized this as ",[263,489,490],{},"\"Unused JavaScript\""," because the same CSS strings existed inside the JS bundle.",[17,493,494,495,498,499,274],{},"We disabled global style injection (",[245,496,497],{},"loadStyles: false",") and registered only the components required by the app. The initial HTML payload dropped from 501 KB to ",[29,500,501],{},"10 KB",[17,503,504],{},"Later, when migrating the public pages to SSR, style injection was re-enabled selectively only for the ~12 components rendered during server side generation.",[219,506],{},[222,508,510],{"id":509},"issue-2-the-lcp-image-was-hidden-behind-3-network-roundtrips","Issue 2: The LCP Image Was Hidden Behind 3 Network Roundtrips",[17,512,513,514,516],{},"The largest contentful element on the homepage is the hero background photo. It was defined via CSS ",[245,515,358],{},". To request this asset, the browser had to:",[518,519,520,524,527,530,533,536],"ol",{},[521,522,523],"li",{},"Download and parse the HTML shell.",[521,525,526],{},"Fetch and execute the JavaScript application bundle.",[521,528,529],{},"Wait for the blocking API fetch call on initial page setup.",[521,531,532],{},"Mount the DOM tree.",[521,534,535],{},"Parse the CSS rules to discover the image URL.",[521,537,538],{},"Trigger the 359 KB JPEG download.",[17,540,541],{},"On slow 4G networks with high RTT (~150ms), the hero image only began downloading after ~4 seconds.",[543,544,546],"h3",{"id":545},"fixes-applied","Fixes Applied:",[548,549,550,568,574],"ul",{},[521,551,552,563,564,567],{},[29,553,554,555,558,559,562],{},"Native ",[245,556,557],{},"\u003Cimg>"," tag with ",[245,560,561],{},"fetchpriority=\"high\"",":"," Enabled the browser's preload scanner to discover and fetch the image immediately upon receiving HTML. Added responsive ",[245,565,566],{},"srcset"," (800/1200/1600 px), reducing mobile image downloads to 34 KB.",[521,569,570,573],{},[29,571,572],{},"WebP Conversion:"," Optimized the 1600px asset from 359 KB down to 98 KB.",[521,575,576,579,580,583],{},[29,577,578],{},"Non-blocking API Fetching:"," Made the hero section render immediately with static elements, while fetching property listings asynchronously via ",[245,581,582],{},"useLazyFetch"," with skeleton loading states.",[219,585],{},[222,587,589],{"id":588},"issue-3-oversized-images-in-small-cards-with-short-caching","Issue 3: Oversized Images in Small Cards with Short Caching",[17,591,592],{},"During upload, images were processed to 2000px WebP files — suitable for full-screen property galleries, but excessive for 400px property cards on search pages. This sent ~500 KB per card across 6 featured listings on the home page.",[17,594,595,596,599,600,603],{},"Additionally, Supabase Storage served assets with ",[245,597,598],{},"Cache-Control: max-age=3600"," (4 hours). We updated headers and Cloudflare CDN caching rules to 1 year (",[245,601,602],{},"max-age=31536000, immutable",").",[605,606,607],"blockquote",{},[17,608,609,612,613,616],{},[29,610,611],{},"What if an image needs to be updated?"," We follow standard ",[29,614,615],{},"Cache Busting",": whenever an image is updated or replaced, its URL path or filename is updated with a new version or hash (e.g., adding a version suffix or content hash). Because the URL changes, browsers recognize it as a brand-new asset and fetch it immediately, safely bypassing the 1-year cache of the old URL.",[17,618,619,620,623,624,627],{},"Explicit ",[245,621,622],{},"width"," and ",[245,625,626],{},"height"," attributes were added to all property image components via API DTO payload data, eliminating layout shifts.",[219,629],{},[222,631,633],{"id":632},"issue-4-eliminating-micro-gargoyles","Issue 4: Eliminating Micro-Gargoyles",[17,635,636],{},"Small inefficiency sources accumulated into substantial overhead:",[548,638,639,645,654,667,681],{},[521,640,641,644],{},[29,642,643],{},"Uncached Static Assets:"," Configured Nitro route rules to serve static fonts with 1-year immutable caching and static images/icons with 30-day cache durations.",[521,646,647,650,651,274],{},[29,648,649],{},"158 Automatic Prefetches:"," Disabled automatic route chunk prefetching via build manifest hooks. Replaced it with viewport-intersection prefetching via ",[245,652,653],{},"\u003CNuxtLink>",[521,655,656,659,660,663,664,274],{},[29,657,658],{},"238 KB SVG Icon Sprite:"," Replaced a large inline Vue icon component with an external SVG sprite hosted in ",[245,661,662],{},"public/",", compressed with SVGO, and referenced using ",[245,665,666],{},"\u003Cuse href=\"/icons/sprite.svg#icon-name\">",[521,668,669,672,673,676,677,680],{},[29,670,671],{},"Icon Font Blocking:"," Self-hosted the ",[245,674,675],{},".woff2"," font with ",[245,678,679],{},"font-display: swap"," and preloading.",[521,682,683,686,687,690,691,693,694,697],{},[29,684,685],{},"Accessibility & SEO:"," Added ",[245,688,689],{},"aria-label"," attributes to icon-only buttons, fixed header hierarchy (",[245,692,222],{}," to ",[245,695,696],{},"h4","), added canonical links and meta descriptions, and adjusted brand green text contrast to pass WCAG AA standards (4.5:1 minimum).",[219,699],{},[222,701,703],{"id":702},"the-pivot-selective-ssr-for-public-pages","The Pivot: Selective SSR for Public Pages",[17,705,706,707,274],{},"With core optimizations in place, we switched the public showcase to ",[29,708,709],{},"Server-Side Rendering (SSR)",[17,711,712,713,716],{},"The primary driver was ",[29,714,715],{},"social link previews",". When brokers shared property links on WhatsApp or social networks, the previews rendered blank thumbnails without pricing or titles because preview bots do not execute client-side JavaScript.",[17,718,719,720,723],{},"Using Nuxt ",[245,721,722],{},"routeRules",", we configured a hybrid rendering model:",[548,725,726,732],{},[521,727,728,731],{},[29,729,730],{},"SSR enabled by default:"," For homepage, search, landing pages, and individual property pages.",[521,733,734,739,740,603],{},[29,735,736,738],{},[245,737,247],{}," (SPA):"," For the administrative panel, authentication screens, and user favorites (which rely entirely on client ",[245,741,742],{},"localStorage",[744,745,750],"pre",{"className":746,"code":747,"language":748,"meta":749,"style":749},"language-typescript shiki shiki-themes github-light github-dark","// sample\nexport default defineNuxtConfig({\n  routeRules: {\n    '/dashboard/**': { ssr: false },\n    '/checkout': { ssr: false },\n    '/catalog/**': { swr: 3600 }\n  }\n})\n","typescript","",[245,751,752,761,779,785,802,814,829,835],{"__ignoreMap":749},[753,754,757],"span",{"class":755,"line":756},"line",1,[753,758,760],{"class":759},"sJ8bj","// sample\n",[753,762,764,768,771,775],{"class":755,"line":763},2,[753,765,767],{"class":766},"szBVR","export",[753,769,770],{"class":766}," default",[753,772,774],{"class":773},"sScJk"," defineNuxtConfig",[753,776,778],{"class":777},"sVt8B","({\n",[753,780,782],{"class":755,"line":781},3,[753,783,784],{"class":777},"  routeRules: {\n",[753,786,788,792,795,799],{"class":755,"line":787},4,[753,789,791],{"class":790},"sZZnC","    '/dashboard/**'",[753,793,794],{"class":777},": { ssr: ",[753,796,798],{"class":797},"sj4cs","false",[753,800,801],{"class":777}," },\n",[753,803,805,808,810,812],{"class":755,"line":804},5,[753,806,807],{"class":790},"    '/checkout'",[753,809,794],{"class":777},[753,811,798],{"class":797},[753,813,801],{"class":777},[753,815,817,820,823,826],{"class":755,"line":816},6,[753,818,819],{"class":790},"    '/catalog/**'",[753,821,822],{"class":777},": { swr: ",[753,824,825],{"class":797},"3600",[753,827,828],{"class":777}," }\n",[753,830,832],{"class":755,"line":831},7,[753,833,834],{"class":777},"  }\n",[753,836,838],{"class":755,"line":837},8,[753,839,840],{"class":777},"})\n",[543,842,844],{"id":843},"ssr-hydration-edge-cases-fixed","SSR Hydration Edge Cases Fixed:",[518,846,847,860,874],{},[521,848,849,852,853,855,856,859],{},[29,850,851],{},"Hydration Mismatches:"," Ensured ",[245,854,742],{}," reads and non-deterministic operations (such as array shuffling) run inside ",[245,857,858],{},"onMounted"," rather than top-level setup.",[521,861,862,865,866,869,870,873],{},[29,863,864],{},"Server-side Reactivity:"," Replaced ",[245,867,868],{},"watch"," handlers populating data arrays with reactive ",[245,871,872],{},"computed"," properties.",[521,875,876,879,880,623,883,886,887,274],{},[29,877,878],{},"PrimeVue Context Services:"," Preserved ",[245,881,882],{},"\u003CToast />",[245,884,885],{},"\u003CConfirmDialog />"," instances in the root layout to maintain global service registration for ",[245,888,889],{},"useToast()",[219,891],{},[222,893,895],{"id":894},"delivery-optimization-compression-chunk-bundling","Delivery Optimization: Compression & Chunk Bundling",[17,897,898],{},"To ensure optimal transport performance, we tuned Nitro and Rollup:",[548,900,901,910,916],{},[521,902,903,906,907,274],{},[29,904,905],{},"Brotli Server Compression:"," Implemented a Nitro server plugin to pre-compress SSR HTML responses using Brotli/Gzip before reaching the reverse proxy, shrinking raw HTML payloads from 196 KB to ",[29,908,909],{},"24 KB",[521,911,912,915],{},[29,913,914],{},"Rollup Chunk Consolidation:"," Merged fragmented JS build files (74 of which were smaller than 10 KB). Reduced total build chunks from 101 to 71, and homepage JS requests from 44 down to 19.",[521,917,918,921,922,925,926,929],{},[29,919,920],{},"Hero Image Preloading:"," Injected HTTP ",[245,923,924],{},"Link: rel=preload"," headers with ",[245,927,928],{},"imagesrcset"," so the browser prioritizes the hero image at the top of the network waterfall.",[219,931],{},[222,933,935],{"id":934},"key-takeaways","Key Takeaways",[518,937,938,947,953,959],{},[521,939,940,943,944,946],{},[29,941,942],{},"Inspect Raw Payloads:"," Automated summaries highlight symptoms, but raw ",[245,945,257],{}," inspection exposes root causes like hidden inline styles.",[521,948,949,952],{},[29,950,951],{},"SSR Drives Conversion:"," Rich WhatsApp previews and search engine indexing are core business requirements for public portals.",[521,954,955,958],{},[29,956,957],{},"Hydrate Mindfully:"," Client-only state (storage, media queries, randomness) must initialize neutrally during SSR and update post-mount.",[521,960,961,964],{},[29,962,963],{},"Target Critical CSS:"," Prune UI framework CSS imports so only active components load in critical rendering paths.",[219,966],{},[222,968,970],{"id":969},"summary","Summary",[17,972,973,974,976,977,274],{},"We transformed an SPA serving 500 KB of unused inline CSS into a selective SSR application with 24 KB Brotli-compressed HTML, preloaded hero image, and full social preview support — raising Lighthouse Performance from ",[29,975,31],{}," and cutting LCP from ",[29,978,35],{},[980,981,982],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":749,"searchDepth":763,"depth":763,"links":984},[985,986,988,989,992,993,994,997,998,999],{"id":224,"depth":763,"text":225},{"id":253,"depth":763,"text":987},"The Audit Report Was Generic. curl Revealed the Truth.",{"id":464,"depth":763,"text":465},{"id":509,"depth":763,"text":510,"children":990},[991],{"id":545,"depth":781,"text":546},{"id":588,"depth":763,"text":589},{"id":632,"depth":763,"text":633},{"id":702,"depth":763,"text":703,"children":995},[996],{"id":843,"depth":781,"text":844},{"id":894,"depth":763,"text":895},{"id":934,"depth":763,"text":935},{"id":969,"depth":763,"text":970},"2026-09-18","How I fixed LCP down from 6.8s to 2.0s and boosted Nuxt 4 performance from 69 to 97 by fixing inline CSS, selective SSR, images, and caching.","md",true,{},"/en/blog/de-69-a-97-no-lighthouse",{"title":7,"description":1001},"en/blog/de-69-a-97-no-lighthouse",[1009,1010,1011,1012,1013],"Nuxt","Web Performance","Lighthouse","SSR","Core Web Vitals","2RAX4F4bAIb6qGUZWRa5vjSrUdko-GWif7jChVVBxRc","en",false,1789775732480]