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.
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.
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:
Core Web Vitals 2026 består av tre mätvärden:
| Mätvärde | Vad det mäter | Bra | Behöver förbättring | Dåligt |
|---|---|---|---|---|
| LCP | Laddningsprestanda | ≤ 2,5s | 2,5s – 4,0s | > 4,0s |
| CLS | Visuell stabilitet | ≤ 0,1 | 0,1 – 0,25 | > 0,25 |
| INP | Responsivitet | ≤ 200ms | 200ms – 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.
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.
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:
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.
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:
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.
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:
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.
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:
<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>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.
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400 på statiska sidor.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:
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.
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 på <img>-taggar.
// 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:
// 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:
// 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.
// 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:
// 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:
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:
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.
offsetHeight) och sedan skriva dem (som att ändra style.height) i en loop tvingar webbläsaren att räkna om layouten synkront.Den mest effektfulla tekniken för INP är att bryta upp långa uppgifter så att webbläsaren kan bearbeta användarinteraktioner mellan bitarna:
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:
function yieldToMain(): Promise<void> {
if ("scheduler" in globalThis && "yield" in scheduler) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}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):
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.
För hanterare som utlöser dyra operationer (API-anrop, tung beräkning), debouncera dem:
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.
Om du har genuint tung beräkning (parsning av stor JSON, bildmanipulering, komplexa beräkningar), flytta den helt bort från huvudtråden:
// 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;
}
}
});// 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:
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 är bra, men standardkonfigurationen lämnar prestanda på bordet:
// 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:
// 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.
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:
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.
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:
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.
Next.js låter dig konfigurera cachning och revalidering på routesegmentnivå:
// app/blog/page.tsx
// Revalidate this page every hour
export const revalidate = 300;
// 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";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.
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.
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:
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:
// 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:
SELECT
origin,
p75_lcp,
p75_cls,
p75_inp,
form_factor
FROM
`chrome-ux-report.materialized.metrics_summary`
WHERE
origin = 'https://yoursite.com'
AND yyyymm = 202603Tredjepartsskript är den största prestandamördaren på de flesta webbplatser. Här är vad jag hittade och vad jag gjorde åt det.
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:
// 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:
"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å.
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.
"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>
);
}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:
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").Promise, fetch eller Array.prototype.includes. 2026 behöver du dem inte.Jag använder Next.js bundle analyzer för att hitta överdimensionerade chunks:
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
const nextConfig = {
// your config
};
export default process.env.ANALYZE === "true" ? withBundleAnalyzer({ enabled: true })(nextConfig) : nextConfig;ANALYZE=true npm run buildDet ö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.
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:
// 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 },
);Initiala mätningar (CrUX-data, mobil, 75:e percentilen):
Lighthouse-poäng: 62.
LCP-analys: LCP-elementet var sidrubriken (<h1>), som borde renderas direkt. Men den försenades av:
CLS-analys: Tre källor:
INP-analys: Regex-testverktyget var den värsta boven. Varje knapptryckning i regex-fältet utlöste:
Total tid per knapptryckning: 280-400ms.
Vecka 1: LCP och CLS
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
Tog bort komponentbibliotekets CSS. Skrev om de 3 komponenterna jag använde med Tailwind. Total borttagen CSS: 180KB. Renderingsblockerande CSS: eliminerad
Lade till explicita dimensioner på alla verktygskortskomponenter och använde aspect-ratio för responsiva layouter. CLS från kort: 0,04 → 0,00
Flyttade cookie-bannern till position: fixed längst ner på skärmen. CLS från banner: 0,02 → 0,00
Vecka 2: INP
startTransition: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
Lade till debouncing på JSON-formaterarens input-hanterare (300ms fördröjning). INP på JSON-formateraren: 260ms → 60ms
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
Efter två veckors optimering (CrUX-data, mobil, 75:e percentilen):
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).
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:
priority (eller fetchPriority="high") på LCP-bilden<head>next/fontwidth och heightnext/font med storleksjusterade reservtypsnittposition: fixed/absolute eller reserverat utrymmestartTransitionPrestandaoptimering ä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.