Core Web Vitals uden panik (12 små hacks jeg ville ønske jeg kendte før)

Core Web Vitals uden panik (12 små hacks jeg ville ønske jeg kendte før)

“Performance score: 32.”

Det stod der i Lighthouse, første gang jeg kørte testen på et ellers ganske uskyldigt lille Vite-projekt en sen aften ved spisebordet. Ingen kæmpe framework, ingen tusind komponenter. Bare et lille hobby-dashboard. Alligevel føltes det som om jeg havde bygget siden i bly.

Jeg gjorde det, mange gør: lukkede rapporten, sukkede og tænkte “det må være noget avanceret, jeg ikke forstår endnu”. Det var det ikke. Det var mest billeder, fonts og lidt for meget JavaScript.

I dag griber jeg det helt anderledes an. Og det er faktisk ret begyndervenligt, hvis man tager det i små bidder.

1. Start altid med en måling, ikke en mavefornemmelse

Jeg starter altid samme sted nu: i Chrome DevTools med Lighthouse.

Sådan kører du en hurtig Lighthouse-rapport

Åbn din side i Chrome. Højreklik og vælg “Inspicer”. Gå til fanen “Lighthouse”.

Her gør jeg typisk:

  • Vælger “Mobile” (mobil afslører problemer hurtigst)
  • Sætter kun flueben i “Performance” for at spare tid
  • Klikker “Analyze page load” og venter 10-20 sekunder

Så får du en score og nogle grafer. Det er fristende at stirre sig blind på tallet, men til Core Web Vitals kigger jeg på tre ting:

  • Largest Contentful Paint (LCP) – hvor hurtigt den største synlige ting loader
  • Cumulative Layout Shift (CLS) – hvor meget layoutet hopper rundt
  • Interaction to Next Paint (INP) – hvor hurtigt siden reagerer på klik/tastetryk

Du finder dem under “Metrics” og nogle gange i toppen som små badges. Jeg skriver dem ned i en lille tekstfil: f.eks. LCP: 3.8s, CLS: 0.21, INP: 320ms. Så kan jeg se, om mine ændringer faktisk hjælper.

2. LCP, CLS og INP forklaret uden lærebog

Jeg plejede at tænke: “Så længe siden ser hurtig ud på min maskine, er det fint.” Det var før jeg forstod, hvad de tre tal egentlig repræsenterer.

LCP: Den største ting foldet ud

LCP er bare svaret på: “Hvornår er det vigtigste indhold synligt?” Typisk:

  • et stort hero-billede
  • en kæmpe overskrift
  • en stor kort-komponent eller lignende

Hvis det først dukker op efter 4-5 sekunder, føles siden sløv, uanset hvor meget spinner-animation du viser.

CLS: Den irriterende hoppe-linje

CLS er det, du mærker, når du er ved at klikke på en knap, og hele siden hopper, fordi et billede eller en annonce pludselig loader. Det føles billigt og ufærdigt.

INP: Hvor lang tid føles et klik

INP handler om, hvor hurtigt siden reagerer, når du gør noget. Klikker du på en knap, og der går et halvt sekund, før noget sker? Så er din INP sandsynligvis dårlig.

Jeg tænker på det som tre spørgsmål:

  • LCP: Hvor hurtigt ser siden “rigtig” ud?
  • CLS: Holder tingene op med at hoppe rundt?
  • INP: Føles det responsivt, når jeg bruger det?

3. Tre hurtige LCP-gevinster du kan teste i Lighthouse

Mit første projekt tabte næsten al LCP-score på et gigantisk hero-billede. Jeg gjorde alt det forkerte: fuld opløsning, ingen komprimering, og jeg lod CSS skalere det ned.

Win 1: Brug et mindre, moderne billedformat

Før havde jeg noget i den her stil:

<img src="/images/hero-large.jpg" alt="Dashboard" />

Billedet var 4000px bredt. Min skærm er ikke.

Efter skiftede jeg til WebP og en fornuftig bredde, f.eks. 1600px, og gav browseren et hint:

<img 
  src="/images/hero-1600.webp" 
  alt="Dashboard"
  width="1600" 
  height="900" 
  loading="lazy" 
/>

Ja, loading="lazy" er mere en LCP-gevinst for ting nederst på siden, men det hjælper generelt. Det vigtigste her er: mindre fil, fornuftigt format.

Vil du nørde billedstørrelser, har jeg også skrevet en artikel om at optimere billeder til web, men du kan nå langt bare med WebP og realistiske dimensioner.

Win 2: Sørg for at dit hero-billede ligger i HTML, ikke bliver hentet via JavaScript

En typisk rookie-fejl i SPA-projekter er at lade JavaScript kontrollere alt, også det helt statiske.

Før brugte jeg et React-component som først blev loaded efter bundlen:

// Hero.tsx
export function Hero() {
  return <img src="/images/hero-1600.webp" alt="Dashboard" />
}

Og så blev hele appen mounted på et tomt <div id="root">. Browseren kan ikke begynde at hente billedet, før JavaScript er downloaded og kørt.

En bedre løsning i små projekter er at lade det vigtigste indhold ligge direkte i HTML’en serveret af hosten. Hvis du bruger Vite med en enkel entry, kan du f.eks. have en server-renderet side eller en statisk HTML med dit hero-billede, så browseren begynder at hente det med det samme.

Win 3: Gør dine webfonts mindre irriterende

Jeg har flere gange ødelagt LCP med én ting: tunge Google Fonts uden fallback.

Før:

<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" />

Efter jeg forstod, hvad der foregik, gik jeg over til:

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" />

Og i CSS:

body {
  font-family: "Roboto", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}

display=swap gør, at siden viser fallback-fonten med det samme og skifter, når fonten er hentet. Det forbedrer både oplevelsen og ofte LCP.

4. Tre nemme CLS-fix: stop hoppe-layoutet

CLS tør jeg godt indrømme, at jeg ignorerede længe. “Det er da fint, at ting flytter sig lidt”, tænkte jeg. Indtil jeg prøvede min egen side på mobilen og ramte den forkerte knap tre gange.

Win 4: Sæt bredde og højde på billeder

Den klassiske synder er billeder uden dimensioner.

Før:

<img src="/avatars/user1.webp" alt="Bruger" />

Efter:

<img 
  src="/avatars/user1.webp" 
  alt="Bruger" 
  width="80" 
  height="80" 
  loading="lazy" 
/>

Bare det at sætte width/height giver browseren pladsen at arbejde med, så layoutet ikke hopper, når billedet endelig er hentet.

Win 5: Undgå at indsætte ting ovenfor eksisterende indhold

En fejl jeg lavede i et lille dashboard: Jeg havde en notifikation-bar der først blev vist efter et async fetch.

Før (pseudo):

// Efter data er hentet
setShowBanner(true)

return (
  <main>
    {showBanner && <Banner />}
    <Dashboard />
  </main>
)

Når banneret blev vist, skubbede det hele dashboardet ned. CLS gik i vejret.

Efter flyttede jeg banneret under headeren og gav det en reserveret højde fra start, f.eks. via CSS:

.banner-placeholder {
  min-height: 48px;
}

Og i JSX:

<header>...</header>
<div className="banner-placeholder">
  {showBanner && <Banner />}
</div>
<main>
  <Dashboard />
</main>

Nu hopper resten ikke.

Win 6: Vær forsigtig med fonts og FOUT/FOIT

Nogle gange kommer CLS faktisk fra fonts, ikke fra billeder. Hvis din tekst først vises med én font og så skifter til en bredere/smalere font, kan det flytte linjer.

Her hjælper:

  • system-fonts (ingen ekstern fetch)
  • font-display: swap i selvhostede fonts
  • ikke at loade 5 forskellige font-familier og 10 vægte

Jeg er endt med i mange små projekter bare at bruge system-ui. Det ser fint ud og er lynhurtigt. Ikke fancy, men behageligt.

5. Tre simple greb til bedre INP i små projekter

INP fik jeg først øje på, da en knap i min lille SPA føltes sløv, selv om alt “virkede”. Lighthouse brokkede sig, og jeg opdagede, hvor meget en enkelt tung funktion kan trække oplevelsen ned.

Win 7: Flyt tunge beregninger væk fra klik-øjeblikket

Jeg havde noget i den her stil:

button.addEventListener("click", () => {
  const result = heavyCalculation(data)
  renderResult(result)
})

Når du laver alt arbejdet inde i click-handleren, blokerer du UI-tråden. Et lille delay føles hurtigt tungt.

En bedre strategi er at:

  • forberede data tidligere (f.eks. efter fetch)
  • dele arbejdet op i mindre bidder
  • vise et lille “beregner”-state hurtigt og lave tunge ting en anelse bagefter

Ikke alt kan magisk optimeres, men bare at flytte store loops væk fra click-handleren hjælper.

Win 8: Debounce input-events

Et typisk mønster: du kalder API for hvert tastetryk i et søgefelt.

Før:

input.addEventListener("input", (e) => {
  fetch(`/api/search?q=${e.target.value}`)
})

Efter med simple debounce:

let timeoutId

input.addEventListener("input", (e) => {
  clearTimeout(timeoutId)
  const value = e.target.value
  timeoutId = setTimeout(() => {
    fetch(`/api/search?q=${value}`)
  }, 300)
})

Nu reagerer UI stadig med det samme (du skriver frit), men netværks-arbejdet sker sjældnere. Mindre pres på browseren, bedre INP.

Win 9: Ryd op i unødvendige event listeners

I et af mine projekter havde jeg bundet scroll-events flere steder uden at tænke over det. Det kan hurtigt blive til meget arbejde for browseren.

Jeg gør nu det, at jeg:

  • samler scroll-handling ét sted
  • bruger passive: true på scroll/ touch, når muligt
  • fjerner lyttere igen, når komponenter unmountes

Det er små ting, men især på mobil kan det mærkes.

6. Vite-specifikke ting jeg ofte glemmer (og som koster score)

Jeg bruger Vite til næsten alle mine små projekter. Det er hurtigt i dev, men jeg glemte længe at tjekke, hvad jeg egentlig sendte i produktion.

Win 10: Kør en rigtig production build og tjek størrelsen

I Vite er første skridt altid:

npm run build
npm run preview

Så tester du den version, brugerne faktisk ser. Når du bygger, får du også et output om bundle-størrelser.

Hvis din index.js pludselig er på 800 kB, er det et hint. Gå koden igennem og kig efter:

  • hele UI-biblioteker for én knaps skyld
  • store ikonsæt hvor du kun bruger 2 ikoner
  • moment.js eller lignende tunge libs, hvor du kunne klare dig med noget simplere

Lazy loading af sjældne views

I en lille SPA med Vite og React havde jeg en admin-side, som kun jeg selv brugte. Alligevel blev den loaded for alle.

Før:

import { AdminPage } from "./AdminPage";

<Routes>
  <Route path="/admin" element={<AdminPage />} />
</Routes>

Efter med lazy loading:

import { lazy, Suspense } from "react";

const AdminPage = lazy(() => import("./AdminPage"))

<Routes>
  <Route 
    path="/admin" 
    element={
      <Suspense fallback={<p>Loader...</p>}>
        <AdminPage />
      </Suspense>
    } 
  />
</Routes>

Så kommer admin-koden først ned, når nogen faktisk besøger /admin. Det hjælper både LCP og INP på forsiden.

7. Billederne: den nemmeste performance-gevinst du kan få

Hvis jeg kun måtte optimere én ting i et begynderprojekt, ville jeg tage billederne. De er næsten altid for store.

Basis-regler jeg selv følger

  • Eksporter til WebP eller AVIF, hvis muligt
  • Brug realistiske størrelser (ingen 4000px brede helskærmsbilleder)
  • Brug loading="lazy" på alle billeder under folden

Et simpelt eksempel:

<img 
  src="/images/card-400.webp" 
  alt="Projekt" 
  width="400" 
  height="250" 
  loading="lazy" 
/>

Vil du være ekstra flittig, kan du bruge srcset og sizes, så mobilen får en mindre version, men for de fleste små projekter er det vigtigste bare at slippe for de helt groteske filstørrelser.

8. Caching basics på simple hosts

Her bliver mange nervøse, fordi “cache headers” lyder som noget, man skal være DevOps for at røre ved. I praksis er det ret simpelt på de fleste statiske hosts.

På Netlify, Vercel og lignende sættes der ofte fornuftige defaults, især for hashed assets (filer med .hash.js i navnet). De kan typisk caches længe.

Det jeg kigger efter, er typisk:

  • at index.html ikke caches for længe (så deploys slår igennem)
  • at bundler og billeder med hash får lang cache

Nogle hosts kan styres via en _headers-fil eller lignende. Et simpelt eksempel kunne være:

/*
  Cache-Control: public, max-age=0, must-revalidate

/assets/*
  Cache-Control: public, max-age=31536000, immutable

Så siger du: HTML skal altid tjekkes igen, men assets (med hash i filnavnet) kan ligge længe i cachen.

Hvis du er helt ny i det her, starter jeg ofte bare med standard-opsætningen på f.eks. Netlify og tjekker via DevTools > Network, hvilke cache-headers der kommer retur. Så kan du bygge videre derfra, når du er tryg.

9. Mini-tjek før du viser dit projekt frem i en ansøgning

Hvis du bygger noget til din portfolio, er Core Web Vitals faktisk en ret fin måde at skille sig lidt ud på. Du behøver ikke 100 i score, men du kan vise, at du har tænkt over performance.

Min egen lille tjekliste

Jeg kører typisk det her igennem, inden jeg sender linket til nogen:

  • Kør Lighthouse på mobil, skriv LCP, CLS og INP ned
  • Tjek startsiden i DevTools > Network: hvor stor er total download?
  • Åbn siden på min egen mobil på 4G og mærk, om den føles “tung”
  • Tjek hurtigt om billeder har width/height og loading=”lazy”
  • Sørg for at jeg bruger npm run build og ikke dev-serveren til deploy

Hvis du vil gå dybere, ligger der også andre artikler på Coding Class om f.eks. SPA vs server routing og om at forstå bundler og deploy, som spiller fint sammen med det her.

10. En sidste bemærkning om score-jagt

Jeg jagtede selv 100/100 længe. Det er meget tilfredsstillende, når cirklen bliver grøn. Men i små projekter er det efter min mening vigtigere at forstå, hvad der trækker ned, end at få alle tal perfekte.

Hvis du kan forklare: “Min LCP er høj, fordi jeg loader et stort hero-billede, og jeg har valgt at prioritere kvalitet her” eller “min INP lider lidt på den her side, fordi jeg laver tunge beregninger, som jeg endnu ikke har fået flyttet ud”, så er du allerede foran rigtig mange.

Og ja, jeg mener stadig, at en portfolio-side med en stabil 80’er-score og tydelig forståelse af Core Web Vitals er mere imponerende end en kunstigt optimeret 100’er uden at vide hvorfor. Selv om det sikkert ikke er det, LinkedIn gerne vil høre.

Som tommelfingerregel: LCP under 2,5s er godt, 2,5-4s kræver forbedring og over 4s er dårligt. CLS under 0,1 er godt, 0,1-0,25 kræver forbedring og over 0,25 er dårligt. INP bør helst være under 200ms, 200-500ms kræver forbedring og over 500ms er problematisk.
Brug Real User Monitoring: installer web-vitals-biblioteket og send metrikkerne til dit analytics- eller observability-værktøj, eller se aggregater i Chrome UX Report (CrUX). Sørg for sampling, brugertilladelse og at splitte data på enheder/geografi, så du kan spore reelle problemer over tid.
Aktivér 'Show layout shift regions' i DevTools Rendering eller optag en Performance-profil og kig efter Layout Shift events for at se hvilke elementer, der flytter sig. Typiske fixes er at reservere plads med width/height eller aspect-ratio, undgå dynamisk indsatte elementer over eksisterende indhold og preload kritiske assets som webfonts.
Brug en billedpipeline i byggeprocessen eller en CDN med on-the-fly konvertering (f.eks. Cloudflare Images, Imgix) og generér flere størrelser/format via sharp, Squoosh CLI eller Vite/Next plugins. Kombinér det med srcset/sizes i HTML og moderne formater som WebP/AVIF for automatisk at give klienten den passende fil.

Mikkel Schrøder er den dér stille type, der i årevis har siddet om aftenen med en kop kaffe og et åbent kodeprojekt, mens resten af huset er ved at falde til ro. Hans interesse for kodning startede, da han som teenager forsøgte at lave en simpel hjemmeside til sit favorit-fodboldhold og opdagede, at man kunne ændre alt ved at rode med HTML og CSS. Siden har han lært tingene ved at prøve sig frem, læse forumtråde og pille ved små projekter, indtil de gjorde det, han ville.

På Coding Class deler han ikke perfekte løsninger fra et glansbillede-univers, men de ting han faktisk selv har bokset med: mærkelige JavaScript-fejl, CSS der ikke opfører sig som forventet, og små Python-scripts, der starter i kaos og ender med at spare tid i hverdagen. Han kan godt lide at vise både den første, halvdårlige løsning og den forbedrede udgave, så du kan se forskellen og forstå tankegangen bag.

Mikkel brænder for at gøre programmering mindre skræmmende for dem, der ikke ser sig selv som "tech-typer". Derfor skriver han på helt almindeligt dansk, med små, konkrete kodeeksempler og fokus på, hvordan du selv kan komme fra teori til noget, der faktisk virker. På Coding Class forsøger han at bygge bro mellem manual-sproget og virkeligheden ved at vise, hvordan det føles at sidde med fejlen klokken 22.30 – og hvad der skulle til, før den forsvandt.

1 kommentar

comments user
Thomas P.

Gemmer, Bruger til foreningens side

Send kommentar

You May Have Missed