Gå till innehåll
·22 min läsning

Core Web Vitals 2026: Vad som faktiskt gör skillnad

Glöm teorin — här är vad jag faktiskt gjorde för att få LCP under 2,5s, CLS till noll och INP under 200ms på en riktig Next.js-produktionssajt. Specifika tekniker, inte vaga råd.

Dela:X / TwitterLinkedIn

Jag lade större delen av två veckor på att göra den här sajten snabb. Inte "ser snabb ut i en Lighthouse-audit på min M3 MacBook" snabb. Verkligen snabb. Snabb på en Android-telefon för 1500 kronor över en skakig 4G-uppkoppling i en tunnelbanetunnel. Snabb där det spelar roll.

Resultatet: LCP under 1,8s, CLS på 0,00, INP under 120ms. Alla tre gröna i CrUX-data, inte bara labresultat. Och jag lärde mig något i processen — de flesta prestandaråd på internet är antingen föråldrade, vaga eller bådadera.

"Optimera dina bilder" är inte ett råd. "Använd lazy loading" utan sammanhang är farligt. "Minimera JavaScript" är uppenbart men säger ingenting om vad du ska skära bort.

Här är vad jag faktiskt gjorde, i den ordning som spelade roll.

Varför Core Web Vitals fortfarande spelar roll 2026#

Låt mig vara direkt: Google använder Core Web Vitals som en rankingssignal. Inte den enda signalen, och inte ens den viktigaste. Innehållsrelevans, bakåtlänkar och domänauktoritet dominerar fortfarande. Men på marginalen — där två sidor har jämförbart innehåll och auktoritet — är prestanda en tiebreaker. Och på internet lever miljontals sidor på de marginalerna.

Men glöm SEO en sekund. Den verkliga anledningen att bry sig om prestanda är användarna. Datan har inte förändrats särskilt mycket de senaste fem åren:

  • 53% av mobilbesök avbryts om en sida tar längre än 3 sekunder att ladda (Google/SOASTA-forskning, stämmer fortfarande)
  • Varje 100ms latens kostar ungefär 1% i konverteringar (Amazons ursprungliga fynd, validerat upprepade gånger)
  • Användare som upplever layoutskiftningar är betydligt mindre benägna att genomföra ett köp eller fylla i ett formulär

Core Web Vitals 2026 består av tre mätvärden:

MätvärdeVad det mäterBraBehöver förbättringDåligt
LCPLaddningsprestanda≤ 2,5s2,5s – 4,0s> 4,0s
CLSVisuell stabilitet≤ 0,10,1 – 0,25> 0,25
INPResponsivitet≤ 200ms200ms – 500ms> 500ms

Dessa tröskelvärden har inte ändrats sedan INP ersatte FID i mars 2024. Men teknikerna för att nå dem har utvecklats, särskilt i React/Next.js-ekosystemet.

LCP: Det som spelar störst roll#

Largest Contentful Paint mäter när det största synliga elementet i viewporten är färdigrenderat. För de flesta sidor är det en hjältebild, en rubrik eller ett stort textblock.

Steg 1: Hitta ditt faktiska LCP-element#

Innan du optimerar något behöver du veta vad ditt LCP-element är. Folk antar att det är deras hjältebild. Ibland är det ett webbtypsnitt som renderar <h1>. Ibland är det en bakgrundsbild via CSS. Ibland är det en <video>-posterbildruta.

Öppna Chrome DevTools, gå till Performance-panelen, spela in en sidladdning och leta efter "LCP"-markören. Den visar exakt vilket element som utlöste LCP.

Du kan också använda web-vitals-biblioteket för att logga det programmatiskt:

tsx
import { onLCP } from "web-vitals";
 
onLCP((metric) => {
  console.log("LCP element:", metric.entries[0]?.element);
  console.log("LCP value:", metric.value, "ms");
});

På den här sajten visade det sig att LCP-elementet var hjältebilden på startsidan och det första textstycket på blogginlägg. Två olika element, två olika optimeringsstrategier.

Steg 2: Preloada LCP-resursen#

Om ditt LCP-element är en bild är det enskilt mest effektfulla du kan göra att preloada den. Som standard upptäcker webbläsaren bilder när den parsar HTML:en, vilket innebär att bildförfrågan inte startar förrän HTML:en är nedladdad, parsad och <img>-taggen är nådd. Preloading flyttar den upptäckten till allra första början.

I Next.js kan du lägga till en preload-länk i din layout eller sida:

tsx
import Head from "next/head";
 
export default function HeroSection() {
  return (
    <>
      <Head>
        <link
          rel="preload"
          as="image"
          href="/images/hero-optimized.webp"
          type="image/webp"
          fetchPriority="high"
        />
      </Head>
      <section className="relative h-[600px]">
        <img
          src="/images/hero-optimized.webp"
          alt="Hero banner"
          width={1200}
          height={600}
          fetchPriority="high"
          decoding="sync"
        />
      </section>
    </>
  );
}

Notera fetchPriority="high". Det här är det nyare Fetch Priority API:et, och det är en game changer. Utan det använder webbläsaren sina egna heuristiker för att prioritera resurser — och de heuristikerna har ofta fel, speciellt när du har flera bilder ovanför folden.

På den här sajten sänkte fetchPriority="high" på LCP-bilden LCP med ~400ms. Det är den enskilt största vinsten jag någonsin fått av en ändring på en rad.

Steg 3: Eliminera renderingsblockerande resurser#

CSS blockerar rendering. All CSS. Om du har en 200KB-stilmall laddad via <link rel="stylesheet"> kommer webbläsaren inte att rita något förrän den är helt nedladdad och parsad.

Lösningen är tredelad:

  1. Inlina kritisk CSS — Extrahera den CSS som behövs för innehåll ovanför folden och inlina den i en <style>-tagg i <head>. Next.js gör detta automatiskt när du använder CSS Modules eller Tailwind med korrekt purging.

  2. Skjut upp icke-kritisk CSS — Om du har stilmallar för innehåll nedanför folden (ett animationsbibliotek för footern, en diagramkomponent), ladda dem asynkront:

html
<link
  rel="preload"
  href="/styles/charts.css"
  as="style"
  onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
  <link rel="stylesheet" href="/styles/charts.css" />
</noscript>
  1. Ta bort oanvänd CSS — Tailwind CSS v4 gör detta automatiskt med sin JIT-motor. Men om du importerar tredjepartsbibliotek för CSS, granska dem. Jag hittade ett komponentbibliotek som importerade 180KB CSS för en enda tooltip-komponent. Ersatte det med en 20 rader lång egen komponent och sparade 170KB.

Steg 4: Serversvarstid (TTFB)#

LCP kan inte vara snabb om TTFB är långsam. Om din server tar 800ms att svara kommer din LCP att vara minst 800ms + allt annat.

På den här sajten (Node.js + PM2 + Nginx på en VPS) mätte jag TTFB till ungefär 180ms vid en cold hit. Här är vad jag gjorde för att hålla det där:

  • ISR (Incremental Static Regeneration) för blogginlägg — sidor förrenderas vid byggtid och revalideras periodiskt. Första besöket serverar en statisk fil direkt från Nginx:s reverse proxy-cache.
  • Edge caching headersCache-Control: public, s-maxage=3600, stale-while-revalidate=86400 på statiska sidor.
  • Gzip/Brotli-komprimering i Nginx — minskar överföringsstorleken med 60-80%.
bash
# nginx.conf snippet
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
 
# Brotli (if ngx_brotli module is installed)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;

Mina före/efter-resultat för LCP:

  • Före optimering: 3,8s (75:e percentilen, CrUX)
  • Efter preload + fetchPriority + komprimering: 1,8s
  • Total förbättring: 53% minskning

CLS: Döden genom tusen skiftningar#

Cumulative Layout Shift mäter hur mycket synligt innehåll flyttar sig under sidladdning. En CLS på 0 betyder att inget skiftade. En CLS över 0,1 betyder att något visuellt irriterar dina användare.

CLS är det mätvärde som de flesta utvecklare underskattar. Du märker det inte på din snabba utvecklingsmaskin med allt cachat. Dina användare märker det på sina telefoner, på långsamma uppkopplingar, där typsnitt laddas sent och bilder dyker upp en efter en.

De vanliga bovarna#

1. Bilder utan explicita dimensioner

Det här är den vanligaste orsaken till CLS. När en bild laddas trycks innehållet nedanför ner. Lösningen är pinsamt enkel: ange alltid width och height<img>-taggar.

tsx
// BAD — causes layout shift
<img src="/photo.jpg" alt="Team photo" />
 
// GOOD — browser reserves space before image loads
<img src="/photo.jpg" alt="Team photo" width={800} height={450} />

Om du använder Next.js <Image> hanteras detta automatiskt så länge du anger dimensioner eller använder fill med en dimensionerad föräldracontainer.

Men här är haken: om du använder fill-läge måste föräldracontainern ha explicita dimensioner, annars orsakar bilden CLS:

tsx
// BAD — parent has no dimensions
<div className="relative">
  <Image src="/photo.jpg" alt="Team" fill />
</div>
 
// GOOD — parent has explicit aspect ratio
<div className="relative aspect-video w-full">
  <Image src="/photo.jpg" alt="Team" fill sizes="100vw" />
</div>

2. Webbtypsnitt som orsakar FOUT/FOIT

När ett anpassat typsnitt laddas renderas text i reservtypsnittet om i det anpassade typsnittet. Om de två typsnitten har olika mått (det har de nästan alltid) skiftar allting.

Den moderna lösningen är font-display: swap kombinerat med storleksjusterade reservtypsnitt:

tsx
// Using next/font — the best approach for Next.js
import { Inter } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  // next/font automatically generates size-adjusted fallback fonts
  // This eliminates CLS from font swapping
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

next/font är genuint en av de bästa sakerna i Next.js. Det laddar ner typsnitt vid byggtid, self-hostar dem (ingen Google Fonts-förfrågan vid runtime) och genererar storleksjusterade reservtypsnitt så att bytet från reserv till anpassat typsnitt orsakar noll layoutskiftning. Jag mätte CLS från typsnitt till 0,00 efter att jag bytte till next/font. Innan, med en standard Google Fonts <link>, var det 0,04-0,08.

3. Dynamisk innehållsinjektion

Annonser, cookie-banners, notifikationsfält — allt som injiceras i DOM:en efter den initiala renderingen orsakar CLS om det trycker ner innehåll.

Lösningen: reservera utrymme för dynamiskt innehåll innan det laddas.

tsx
// Cookie banner — reserve space at the bottom
function CookieBanner() {
  const [accepted, setAccepted] = useState(false);
 
  if (accepted) return null;
 
  return (
    // Fixed positioning doesn't cause CLS because it
    // doesn't affect document flow
    <div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
      <p>We use cookies. You know the drill.</p>
      <button onClick={() => setAccepted(true)}>Accept</button>
    </div>
  );
}

Att använda position: fixed eller position: absolute för dynamiska element är ett CLS-fritt tillvägagångssätt eftersom dessa element inte påverkar det normala dokumentflödet.

4. aspect-ratio CSS-tricket

För responsiva containers där du vet bildförhållandet men inte de exakta dimensionerna, använd CSS-egenskapen aspect-ratio:

tsx
// Video embed without CLS
function VideoEmbed({ src }: { src: string }) {
  return (
    <div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
      <iframe
        src={src}
        className="w-full h-full"
        title="Embedded video"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media"
        allowFullScreen
      />
    </div>
  );
}

aspect-video-verktygsklassen (som är aspect-ratio: 16/9) reserverar exakt rätt mängd utrymme. Ingen skiftning när iframen laddas.

5. Skeleton-skärmar

För innehåll som laddas asynkront (API-data, dynamiska komponenter), visa en skeleton som matchar de förväntade dimensionerna:

tsx
function PostCardSkeleton() {
  return (
    <div className="animate-pulse rounded-lg border p-4">
      <div className="h-48 w-full rounded bg-gray-200" />
      <div className="mt-4 space-y-2">
        <div className="h-6 w-3/4 rounded bg-gray-200" />
        <div className="h-4 w-full rounded bg-gray-200" />
        <div className="h-4 w-5/6 rounded bg-gray-200" />
      </div>
    </div>
  );
}
 
function PostList() {
  const { data: posts, isLoading } = usePosts();
 
  if (isLoading) {
    return (
      <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
        {Array.from({ length: 6 }).map((_, i) => (
          <PostCardSkeleton key={i} />
        ))}
      </div>
    );
  }
 
  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
      {posts?.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

Nyckeln är att PostCardSkeleton och PostCard bör ha samma dimensioner. Om skeletonen är 200px hög och det faktiska kortet är 280px högt får du fortfarande en skiftning.

Mina CLS-resultat:

  • Före: 0,12 (enbart typsnittsbytet var 0,06)
  • Efter: 0,00 — bokstavligen noll, över tusentals sidladdningar i CrUX-data

INP: Nykomlingen som biter#

Interaction to Next Paint ersatte First Input Delay i mars 2024, och det är ett fundamentalt svårare mätvärde att optimera. FID mätte bara fördröjningen innan den första interaktionen bearbetades. INP mäter varje interaktion genom hela sidans livscykel och rapporterar den sämsta (vid 75:e percentilen).

Det innebär att en sida kan ha utmärkt FID men fruktansvärd INP om, säg, att klicka på en dropdown-meny 30 sekunder efter laddning utlöser en 500ms-reflow.

Vad som orsakar hög INP#

  1. Långa uppgifter på huvudtråden — All JavaScript-exekvering som tar mer än 50ms blockerar huvudtråden. Användarinteraktioner som sker under en lång uppgift måste vänta.
  2. Dyra om-renderingar i React — En state-uppdatering som orsakar att 200 komponenter renderas om tar tid. Användaren klickar på något, React reconciliar, och det målas inte förrän om 300ms.
  3. Layout thrashing — Att läsa layoutegenskaper (som offsetHeight) och sedan skriva dem (som att ändra style.height) i en loop tvingar webbläsaren att räkna om layouten synkront.
  4. Stor DOM — Fler DOM-noder innebär långsammare stilberäkning och layout. En DOM med 5 000 noder är märkbart långsammare än en med 500.

Bryta upp långa uppgifter med scheduler.yield()#

Den mest effektfulla tekniken för INP är att bryta upp långa uppgifter så att webbläsaren kan bearbeta användarinteraktioner mellan bitarna:

tsx
async function processLargeDataset(items: DataItem[]) {
  const results: ProcessedItem[] = [];
 
  for (let i = 0; i < items.length; i++) {
    results.push(expensiveTransform(items[i]));
 
    // Every 10 items, yield to the browser
    // This lets pending user interactions get processed
    if (i % 10 === 0 && "scheduler" in globalThis) {
      await scheduler.yield();
    }
  }
 
  return results;
}

scheduler.yield() är tillgängligt i Chrome 129+ (september 2024) och är det rekommenderade sättet att ge plats åt huvudtråden. För webbläsare som inte stöder det kan du falla tillbaka till en setTimeout(0)-wrapper:

tsx
function yieldToMain(): Promise<void> {
  if ("scheduler" in globalThis && "yield" in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

useTransition för icke-brådskande uppdateringar#

React 18+ ger oss useTransition, som berättar för React att vissa state-uppdateringar inte är brådskande och kan avbrytas av viktigare arbete (som att svara på användarinput):

tsx
import { useState, useTransition } from "react";
 
function SearchableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();
 
  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
 
    // This update is urgent — the input must reflect the keystroke immediately
    setQuery(value);
 
    // This update is NOT urgent — filtering 10,000 items can wait
    startTransition(() => {
      const filtered = items.filter((item) =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setFilteredItems(filtered);
    });
  }
 
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleSearch}
        placeholder="Search..."
        className="w-full rounded border px-4 py-2"
      />
      {isPending && (
        <p className="mt-2 text-sm text-gray-500">Filtering...</p>
      )}
      <ul className="mt-4 space-y-2">
        {filteredItems.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

Utan startTransition skulle det kännas segt att skriva i sökfältet eftersom React skulle försöka filtrera 10 000 objekt synkront innan DOM:en uppdateras. Med startTransition uppdateras fältet direkt, och filtreringen sker i bakgrunden.

Jag mätte INP på en verktygssida som hade en komplex input-hanterare. Före useTransition: 380ms INP. Efter: 90ms INP. Det är en 76% förbättring från en ändring av React API.

Debouncing av input-hanterare#

För hanterare som utlöser dyra operationer (API-anrop, tung beräkning), debouncera dem:

tsx
import { useCallback, useRef } from "react";
 
function useDebounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): T {
  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
 
  return useCallback(
    ((...args: unknown[]) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      timeoutRef.current = setTimeout(() => fn(...args), delay);
    }) as T,
    [fn, delay]
  );
}
 
// Usage
function LiveSearch() {
  const [results, setResults] = useState<SearchResult[]>([]);
 
  const search = useDebounce(async (query: string) => {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = await response.json();
    setResults(data.results);
  }, 300);
 
  return (
    <input
      type="text"
      onChange={(e) => search(e.target.value)}
      placeholder="Search..."
    />
  );
}

300ms är mitt standardvärde för debounce. Det är kort nog att användare inte märker fördröjningen, långt nog att förhindra avfyrning vid varje knapptryckning.

Web Workers för tung beräkning#

Om du har genuint tung beräkning (parsning av stor JSON, bildmanipulering, komplexa beräkningar), flytta den helt bort från huvudtråden:

tsx
// worker.ts
self.addEventListener("message", (event) => {
  const { data, operation } = event.data;
 
  switch (operation) {
    case "sort": {
      // This could take 500ms for large datasets
      const sorted = data.sort((a: number, b: number) => a - b);
      self.postMessage({ result: sorted });
      break;
    }
    case "filter": {
      const filtered = data.filter((item: DataItem) =>
        complexFilterLogic(item)
      );
      self.postMessage({ result: filtered });
      break;
    }
  }
});
tsx
// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
 
function useWorker() {
  const workerRef = useRef<Worker>();
 
  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/worker.ts", import.meta.url)
    );
    return () => workerRef.current?.terminate();
  }, []);
 
  const process = useCallback(
    (operation: string, data: unknown): Promise<unknown> => {
      return new Promise((resolve) => {
        if (!workerRef.current) return;
 
        workerRef.current.onmessage = (event) => {
          resolve(event.data.result);
        };
 
        workerRef.current.postMessage({ operation, data });
      });
    },
    []
  );
 
  return { process };
}

Web Workers körs på en separat tråd, så även en 2 sekunder lång beräkning påverkar inte INP alls. Huvudtråden förblir fri att hantera användarinteraktioner.

Mina INP-resultat:

  • Före: 340ms (sämsta interaktionen var regex-testverktyget med komplex input-hantering)
  • Efter useTransition + debouncing: 110ms
  • Förbättring: 68% minskning

De Next.js-specifika vinsterna#

Om du kör Next.js (13+ med App Router) har du tillgång till kraftfulla prestandaprimitiver som de flesta utvecklare inte utnyttjar fullt ut.

next/image — men korrekt konfigurerat#

next/image är bra, men standardkonfigurationen lämnar prestanda på bordet:

tsx
// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  images: {
    formats: ["image/avif", "image/webp"],
    deviceSizes: [640, 750, 828, 1080, 1200],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    minimumCacheTTL: 60 * 60 * 24 * 365, // 1 year
  },
};
 
export default nextConfig;

Viktiga inställningar:

  • formats: ["image/avif", "image/webp"] — AVIF är 20-50% mindre än WebP. Ordningen spelar roll: Next.js försöker AVIF först, faller tillbaka till WebP och sedan till originalformatet.
  • minimumCacheTTL — Standard är 60 sekunder. För en blogg ändras inte bilder. Cachea dem i ett år.
  • deviceSizes och imageSizes — Standardvärdena inkluderar 3840px. Om du inte serverar 4K-bilder, trimma listan. Varje storlek genererar en separat cachad bild, och oanvända storlekar slösar diskutrymme och byggtid.

Och använd alltid sizes-propen för att berätta för webbläsaren vilken storlek bilden kommer att renderas i:

tsx
// Full-width hero image
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  sizes="100vw"
  priority // LCP image — don't lazy load it!
/>
 
// Card image in a responsive grid
<Image
  src="/card.jpg"
  alt="Card"
  width={400}
  height={300}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

Utan sizes kan webbläsaren ladda ner en 1200px-bild för en 300px-plats. Det är slösade bytes och slösad tid.

priority-propen på LCP-bilden är kritisk. Den avaktiverar lazy loading och lägger automatiskt till fetchPriority="high". Om ditt LCP-element är en next/image, lägg bara till priority så är du mestadels i mål.

next/font — Typsnitt utan layoutskiftning#

Jag täckte detta i CLS-avsnittet, men det förtjänar betoning. next/font är den enda typsnitts-laddningslösning jag har sett som konsekvent uppnår noll CLS:

tsx
import { Inter, JetBrains_Mono } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});
 
const jetbrainsMono = JetBrains_Mono({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-mono",
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html
      lang="en"
      className={`${inter.variable} ${jetbrainsMono.variable}`}
    >
      <body className="font-sans">{children}</body>
    </html>
  );
}

Två typsnitt, noll CLS, noll externa förfrågningar vid runtime. Typsnitten laddas ner vid byggtid och serveras från din egen domän.

Streaming med Suspense#

Här blir Next.js riktigt intressant för prestanda. Med App Router kan du streama delar av sidan till webbläsaren allt eftersom de blir klara:

tsx
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
 
export default function BlogPage() {
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
      <div className="lg:col-span-2">
        {/* This loads fast — stream it immediately */}
        <h1 className="text-4xl font-bold">Blog</h1>
 
        {/* This requires a database query — stream it when ready */}
        <Suspense fallback={<PostListSkeleton />}>
          <PostList />
        </Suspense>
      </div>
 
      <aside>
        {/* Sidebar can load independently */}
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>
      </aside>
    </div>
  );
}

Webbläsaren får skalet (rubrik, navigation, layout) direkt. Inläggslistan och sidofältet streamar in allt eftersom deras data blir tillgänglig. Användaren ser en snabb initial laddning, och innehåll fylls i successivt.

Det här är särskilt kraftfullt för LCP. Om ditt LCP-element är rubriken (inte inläggslistan) renderas det direkt oavsett hur lång tid databasfrågan tar.

Route Segment-konfiguration#

Next.js låter dig konfigurera cachning och revalidering på routesegmentnivå:

tsx
// app/blog/page.tsx
// Revalidate this page every hour
export const revalidate = 3600;
 
// app/tools/[slug]/page.tsx
// These tool pages are fully static — generate at build time
export const dynamic = "force-static";
 
// app/api/search/route.ts
// API route — never cache
export const dynamic = "force-dynamic";

På den här sajten använder blogginlägg revalidate = 3600 (1 timme). Verktygssidor använder force-static eftersom deras innehåll aldrig ändras mellan deploys. Sök-API:et använder force-dynamic eftersom varje förfrågan är unik.

Resultatet: de flesta sidor serveras från den statiska cachen, TTFB är under 50ms för cachade sidor, och servern har knappt ansträngt sig.

Mätverktyg: Lita på data, inte dina ögon#

Din uppfattning om prestanda är opålitlig. Din utvecklingsmaskin har 32GB RAM, en NVMe SSD och en gigabit-uppkoppling. Dina användare har inte det.

Mätstacken jag använder#

1. Chrome DevTools Performance-panelen

Det mest detaljerade verktyget som finns. Spela in en sidladdning, titta på flamechart:en, identifiera långa uppgifter, hitta renderingsblockerande resurser. Det är här jag lägger mest felsökningstid.

Viktiga saker att leta efter:

  • Röda hörn på uppgifter = långa uppgifter (>50ms)
  • Layout/Paint-händelser utlösta av JavaScript
  • Stora "Evaluate Script"-block (för mycket JavaScript)
  • Nätverksvattenfallet som visar sent upptäckta resurser

2. Lighthouse

Bra för en snabb kontroll, men optimera inte för Lighthouse-poäng. Lighthouse körs i en simulerad throttlad miljö som inte perfekt matchar verkliga förhållanden. Jag har sett sidor som får 98 i Lighthouse och har 4s LCP i fältet.

Använd Lighthouse för riktningsgivande vägledning, inte som en resultattavla.

3. PageSpeed Insights

Det viktigaste verktyget för produktionssajter eftersom det visar riktiga CrUX-data — faktiska mätningar från verkliga Chrome-användare under de senaste 28 dagarna. Labdata berättar vad som kan hända. CrUX-data berättar vad som faktiskt händer.

4. web-vitals-biblioteket

Lägg till detta på din produktionssajt för att samla in riktiga användarmätvärden:

tsx
// components/analytics/WebVitals.tsx
"use client";
 
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
 
function sendToAnalytics(metric: Metric) {
  // Send to your analytics endpoint
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // "good" | "needs-improvement" | "poor"
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
  });
 
  // Use sendBeacon so it doesn't block page unload
  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", body);
  } else {
    fetch("/api/vitals", {
      body,
      method: "POST",
      keepalive: true,
    });
  }
}
 
export function WebVitals() {
  useEffect(() => {
    onCLS(sendToAnalytics);
    onINP(sendToAnalytics);
    onLCP(sendToAnalytics);
    onFCP(sendToAnalytics);
    onTTFB(sendToAnalytics);
  }, []);
 
  return null;
}

Det ger dig din egen CrUX-liknande data, men med mer detalj. Du kan segmentera per sida, enhetstyp, uppkopplingshastighet, geografisk region — vad du än behöver.

5. Chrome User Experience Report (CrUX)

CrUX BigQuery-datasetet är gratis och innehåller 28 dagars rullande data för miljontals origins. Om din sajt får tillräckligt med trafik kan du fråga din egen data:

sql
SELECT
  origin,
  p75_lcp,
  p75_cls,
  p75_inp,
  form_factor
FROM
  `chrome-ux-report.materialized.metrics_summary`
WHERE
  origin = 'https://yoursite.com'
  AND yyyymm = 202603

Vattenfalls-dödslistan#

Tredjepartsskript är den största prestandamördaren på de flesta webbplatser. Här är vad jag hittade och vad jag gjorde åt det.

Google Tag Manager (GTM)#

GTM i sig är ~80KB. Men GTM laddar andra skript — analys, marknadsföringspixlar, A/B-testverktyg. Jag har sett GTM-konfigurationer som laddar 15 ytterligare skript på totalt 2MB.

Mitt tillvägagångssätt: Använd inte GTM i produktion. Ladda analysskript direkt, skjut upp allt, och använd loading="lazy" för skript som kan vänta:

tsx
// Instead of GTM loading everything
// Load only what you need, when you need it
 
export function AnalyticsScript() {
  return (
    <script
      defer
      src="https://analytics.example.com/script.js"
      data-website-id="your-id"
    />
  );
}

Om du absolut måste använda GTM, ladda det efter att sidan är interaktiv:

tsx
"use client";
 
import { useEffect } from "react";
 
export function DeferredGTM({ containerId }: { containerId: string }) {
  useEffect(() => {
    // Wait until after page load to inject GTM
    const timer = setTimeout(() => {
      const script = document.createElement("script");
      script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
      script.async = true;
      document.head.appendChild(script);
    }, 3000); // 3 second delay
 
    return () => clearTimeout(timer);
  }, [containerId]);
 
  return null;
}

Ja, du förlorar data från användare som studsar inom de första 3 sekunderna. Enligt min erfarenhet är det en kompromiss värd att göra. De användarna konverterade inte ändå.

Chattwidgets#

Live-chattwidgets (Intercom, Drift, Crisp) hör till de värsta bovarna. Intercom ensamt laddar 400KB+ JavaScript. På en sida där 2% av användarna faktiskt klickar på chattknappen är det 400KB JavaScript för 98% av användarna.

Min lösning: Ladda widgeten vid interaktion.

tsx
"use client";
 
import { useState } from "react";
 
export function ChatButton() {
  const [loaded, setLoaded] = useState(false);
 
  function loadChat() {
    if (loaded) return;
 
    // Load the chat widget script only when the user clicks
    const script = document.createElement("script");
    script.src = "https://chat-widget.example.com/widget.js";
    script.onload = () => {
      // Initialize the widget after script loads
      window.ChatWidget?.open();
    };
    document.head.appendChild(script);
    setLoaded(true);
  }
 
  return (
    <button
      onClick={loadChat}
      className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
      aria-label="Open chat"
    >
      {loaded ? "Loading..." : "Chat with us"}
    </button>
  );
}

Oanvänd JavaScript#

Kör Coverage i Chrome DevTools (Ctrl+Shift+P > "Show Coverage"). Det visar dig exakt hur mycket av varje skript som faktiskt används på den aktuella sidan.

På en typisk Next.js-sajt hittar jag vanligtvis:

  • Komponentbibliotek som laddas i sin helhet — Du importerar Button från ett UI-bibliotek, men hela biblioteket bundlas. Lösning: använd tree-shakeable bibliotek eller importera från undersökvägar (import Button from "lib/Button" istället för import { Button } from "lib").
  • Polyfills för moderna webbläsare — Kontrollera om du skickar polyfills för Promise, fetch eller Array.prototype.includes. 2026 behöver du dem inte.
  • Döda feature flags — Kodsökvägar bakom feature flags som har varit "on" i sex månader. Ta bort flaggan och den döda grenen.

Jag använder Next.js bundle analyzer för att hitta överdimensionerade chunks:

tsx
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
 
const nextConfig = {
  // your config
};
 
export default process.env.ANALYZE === "true"
  ? withBundleAnalyzer({ enabled: true })(nextConfig)
  : nextConfig;
bash
ANALYZE=true npm run build

Det öppnar en visuell treemap av dina bundles. Jag hittade ett 120KB datumformateringsbibliotek som jag ersatte med nativa Intl.DateTimeFormat. Jag hittade en 90KB markdown-parser importerad på en sida som inte använde markdown. Små vinster som adderar upp.

Renderingsblockerande CSS#

Jag nämnde detta i LCP-avsnittet, men det är värt att upprepa eftersom det är så vanligt. Varje <link rel="stylesheet"> i <head> blockerar rendering. Om du har fem stilmallar väntar webbläsaren på alla fem innan den ritar något.

Next.js med Tailwind hanterar detta väl — CSS inlinas och är minimalt. Men om du importerar tredjeparts-CSS, granska den:

tsx
// BAD — loads entire library CSS on every page
import "some-library/dist/styles.css";
 
// BETTER — dynamic import so it only loads on pages that need it
const SomeComponent = dynamic(
  () => import("some-library").then((mod) => {
    // CSS is imported inside the dynamic component
    import("some-library/dist/styles.css");
    return mod.SomeComponent;
  }),
  { ssr: false }
);

En verklig optimeringshistoria#

Låt mig gå igenom den faktiska optimeringen av den här sajtens verktygssida. Det är en sida med 15+ interaktiva verktyg, vardera med sin egen komponent, och några av dem (som regex-testaren och JSON-formateraren) är JavaScript-tunga.

Utgångspunkten#

Initiala mätningar (CrUX-data, mobil, 75:e percentilen):

  • LCP: 3,8s — Dåligt
  • CLS: 0,12 — Behöver förbättring
  • INP: 340ms — Dåligt

Lighthouse-poäng: 62.

Utredningen#

LCP-analys: LCP-elementet var sidrubriken (<h1>), som borde renderas direkt. Men den försenades av:

  1. En 200KB CSS-fil från ett komponentbibliotek (renderingsblockerande)
  2. Ett anpassat typsnitt laddat via Google Fonts CDN (FOIT i 800ms på långsamma uppkopplingar)
  3. TTFB på 420ms eftersom sidan serverrenderandes vid varje förfrågan utan cachning

CLS-analys: Tre källor:

  1. Typsnittsbytet från Google Fonts reservtypsnitt till anpassat typsnitt: 0,06
  2. Verktygskort som laddas utan höjdreservering: 0,04
  3. En cookie-banner injicerad överst på sidan som tryckte ner allt: 0,02

INP-analys: Regex-testverktyget var den värsta boven. Varje knapptryckning i regex-fältet utlöste:

  1. En fullständig om-rendering av hela verktygskomponenten
  2. Regex-evaluering mot teststrängen
  3. Syntaxmarkering av regex-mönstret

Total tid per knapptryckning: 280-400ms.

Åtgärderna#

Vecka 1: LCP och CLS

  1. Ersatte Google Fonts CDN med next/font. Typsnittet är nu self-hostat, laddat vid byggtid, med storleksjusterat reservtypsnitt. CLS från typsnitt: 0,06 → 0,00

  2. Tog bort komponentbibliotekets CSS. Skrev om de 3 komponenterna jag använde med Tailwind. Total borttagen CSS: 180KB. Renderingsblockerande CSS: eliminerad

  3. Lade till revalidate = 3600 på verktygssidan och verktygsdetaljsidorna. Första träffen serverrenderas, efterföljande träffar serveras från cache. TTFB: 420ms → 45ms (cachad)

  4. Lade till explicita dimensioner på alla verktygskortskomponenter och använde aspect-ratio för responsiva layouter. CLS från kort: 0,04 → 0,00

  5. Flyttade cookie-bannern till position: fixed längst ner på skärmen. CLS från banner: 0,02 → 0,00

Vecka 2: INP

  1. Omslöt regex-testarens resultatberäkning med startTransition:
tsx
function RegexTester() {
  const [pattern, setPattern] = useState("");
  const [testString, setTestString] = useState("");
  const [results, setResults] = useState<RegexResult[]>([]);
  const [isPending, startTransition] = useTransition();
 
  function handlePatternChange(value: string) {
    setPattern(value); // Urgent: update the input
 
    startTransition(() => {
      // Non-urgent: compute matches
      try {
        const regex = new RegExp(value, "g");
        const matches = [...testString.matchAll(regex)];
        setResults(
          matches.map((m) => ({
            match: m[0],
            index: m.index ?? 0,
            groups: m.groups,
          }))
        );
      } catch {
        setResults([]);
      }
    });
  }
 
  return (
    <div>
      <input
        value={pattern}
        onChange={(e) => handlePatternChange(e.target.value)}
        className={isPending ? "opacity-70" : ""}
      />
      {/* results rendering */}
    </div>
  );
}

INP på regex-testaren: 380ms → 85ms

  1. Lade till debouncing på JSON-formaterarens input-hanterare (300ms fördröjning). INP på JSON-formateraren: 260ms → 60ms

  2. Flyttade hash-generatorns beräkning till en Web Worker. SHA-256-hashning av stora indata sker nu helt utanför huvudtråden. INP på hash-generatorn: 200ms → 40ms

Resultaten#

Efter två veckors optimering (CrUX-data, mobil, 75:e percentilen):

  • LCP: 3,8s → 1,8s (53% förbättring)
  • CLS: 0,12 → 0,00 (100% förbättring)
  • INP: 340ms → 110ms (68% förbättring)

Lighthouse-poäng: 62 → 97.

Alla tre mätvärdena stadigt i "Bra"-intervallet. Sidan känns omedelbar på mobil. Och organisk söktrafik ökade 12% under månaden efter förbättringarna (även om jag inte kan bevisa kausalitet — andra faktorer var i spel).

Checklistan#

Om du inte tar med dig något annat från det här inlägget, här är checklistan jag kör igenom på varje projekt:

LCP#

  • Identifiera LCP-elementet med DevTools
  • Lägg till priority (eller fetchPriority="high") på LCP-bilden
  • Preloada LCP-resurser i <head>
  • Eliminera renderingsblockerande CSS
  • Self-hosta typsnitt med next/font
  • Aktivera Brotli/Gzip-komprimering
  • Använd statisk generering eller ISR där det är möjligt
  • Sätt aggressiva cache-headers för statiska tillgångar

CLS#

  • Alla bilder har explicita width och height
  • Använder next/font med storleksjusterade reservtypsnitt
  • Dynamiskt innehåll använder position: fixed/absolute eller reserverat utrymme
  • Skeleton-skärmar matchar faktiska komponentdimensioner
  • Ingen innehållsinjektion överst på sidan efter laddning

INP#

  • Inga långa uppgifter (>50ms) under interaktionshanterare
  • Icke-brådskande state-uppdateringar omslutna med startTransition
  • Input-hanterare debouncade (300ms)
  • Tung beräkning avlastad till Web Workers
  • DOM-storlek under 1 500 noder där det är möjligt

Allmänt#

  • Tredjepartsskript laddade efter att sidan är interaktiv
  • Bundlestorlek analyserad och tree-shakead
  • Oanvänd CSS borttagen
  • Bilder serverade i AVIF/WebP-format
  • Real user monitoring i produktion (web-vitals-biblioteket)

Avslutande tankar#

Prestandaoptimering är inte en engångsuppgift. Det är en disciplin. Varje ny funktion, varje nytt beroende, varje nytt tredjepartsskript är en potentiell regression. Sajterna som förblir snabba är de där någon bevakar mätvärdena kontinuerligt, inte de där någon gjorde en engångs optimeringsinsats.

Sätt upp real user monitoring. Sätt upp varningar när mätvärden försämras. Gör prestanda till en del av din code review-process. När någon lägger till ett 200KB-bibliotek, fråga om det finns ett 5KB-alternativ. När någon lägger till en synkron beräkning i en event handler, fråga om den kan skjutas upp eller flyttas till en worker.

Teknikerna i det här inlägget är inte teoretiska. De är vad jag faktiskt gjorde, på den här sajten, med riktiga siffror som stöd. Dina resultat kan variera — varje sajt är annorlunda, varje publik är annorlunda, varje infrastruktur är annorlunda. Men principerna är universella: ladda mindre, ladda smartare, blockera inte huvudtråden.

Dina användare kommer inte att skicka ett tackbrev för en snabb sajt. Men de stannar. De kommer tillbaka. Och Google kommer att märka det.

Relaterade inlägg